From 5c939d129b4d814dde817af76fdf256a596ef950 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 9 Aug 2026 08:10:22 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=8C=20feat:=20Add=20Agent=20Plugins=20?= =?UTF-8?q?(Experimental)=20(#14704)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔌 feat: Add Agent Plugins v1.0.0 Support Implements the Agent Plugins 1.0.0 specification so LibreChat can load portable plugin packages: a `plugin.json` manifest, `skills/` holding Agent Skills, `mcp.json` describing MCP servers, and reverse-domain extension directories. - Validate the closed `plugin.json` schema, selecting rules from `$schema` without retrieving it. Unknown top-level fields and a non-object `extensions` field are reported and ignored; every other violation rejects the plugin. - Enforce plugin-root containment through realpath, including for paths whose leaf does not exist, and apply the narrowest failure boundary per component. - Map `mcp.json` onto LibreChat MCP options across stdio, Streamable HTTP, and legacy HTTP+SSE, bypassing the config loader's `${VAR}` process-env expansion so plugin values never resolve against the server environment. - Expand only `${PLUGIN_ROOT}` and `${PLUGIN_DATA}`, once and non-recursively, in `args`, `env` values, and `cwd`; supply both variables to the subprocess after configured `env`, and reject entries that declare them. - Discover skills from the immediate children of `skills/` only, reusing the deployment skill loader so plugin skills are ordinary deployment skills with a distinct id namespace. - Read LibreChat's `ai.librechat` extension directory and hand `hooks/hooks.json` to the Claude hook compatibility layer. - Load operator-installed plugins from `DEPLOYMENT_PLUGINS_DIR` at startup, merging their skills into the deployment skill registry and their MCP servers into the app config. Plugins never displace a configured server or deployment skill. - Add `cwd` to the stdio MCP transport, which the specification requires and LibreChat did not previously support. Component failures stay isolated: a malformed `mcp.json`, an invalid skill, or a bad hooks document never prevents the rest of a plugin from loading. * 🔒 fix: Contain Agent Plugins config at the runtime boundary Review of #14704 surfaced that every real finding sat where the loader's output crosses into LibreChat's existing runtime, not in the specification logic. The loader deliberately left plugin placeholders literal, but downstream layers re-processed the same fields and undid it. - Mark plugin MCP configuration with `source: 'plugin'` and return it verbatim from `processMCPEnv`. Without this a remote plugin could declare `Authorization: Bearer ${OPENAI_API_KEY}` and receive host credentials at its own origin. The gate reads the configuration rather than a caller-supplied flag, so no future call site can reintroduce the leak by omitting it. - Skip `preProcessGraphTokens` for plugin configuration as well; it resolves placeholders into headers, url, and args on the same path. - Reject plugin server names that change under `normalizeServerName`. Tool keys embed the normalized name while request-time resolution uses the raw name, so an unstable name published tools that nothing could resolve. - Reject `__proto__`, `constructor`, and `prototype` as server names, and merge plugin servers with `Object.defineProperty` and an own-property conflict check, so a package cannot reach a prototype setter or collide with an inherited member. - Enforce manifest-name uniqueness before components are accepted; two packages sharing a name would share one `PLUGIN_DATA` directory. - Isolate a failed data-directory creation to the single plugin instead of rejecting the whole scan. - Prefix rejected-plugin diagnostics with the directory, which is the only identifier a package without a valid manifest has. - Type extension namespace contents as JSON rather than `unknown`, and correct the header field-value comment to name obs-text. Verified end to end from the built package: a plugin declaring an environment placeholder in a header reaches the transport with the placeholder intact while operator-authored configuration still resolves normally. * 🔇 fix: Report Agent Plugin hooks that will not run The loader reads `ai.librechat/hooks/hooks.json`, but nothing registers the resulting plan, and startup supplies no hook capabilities. A package declaring hooks was therefore accepted in silence, leaving an operator to believe the hooks ran. Detect the document when no capabilities are registered and report it as unsupported, so the limitation is visible in startup diagnostics rather than inferred from behavior that never happens. * 🧯 test: Restore MCP startup test mocks Carries the two mock additions from #14711 so this branch can prove itself green. `initializeMCPs` now calls `syncStaticTools`, which the server startup specs do not stub, so they fail on every branch that has not picked this up. Drops out of the rebase once #14711 lands. Co-authored-by: Danny Avila --- api/server/index.js | 9 +- api/server/services/initializeMCPs.js | 34 +- .../services/initializeMCPs.plugins.spec.js | 169 +++++++ api/server/services/initializeMCPs.spec.js | 4 + packages/api/src/index.ts | 2 + packages/api/src/mcp/MCPManager.ts | 23 +- packages/api/src/mcp/UserConnectionManager.ts | 18 +- .../api/src/mcp/__tests__/MCPManager.test.ts | 2 + packages/api/src/mcp/connection.ts | 1 + packages/api/src/mcp/types/index.ts | 2 +- packages/api/src/plugins/constants.ts | 28 ++ packages/api/src/plugins/deployment.spec.ts | 197 ++++++++ packages/api/src/plugins/deployment.ts | 266 +++++++++++ packages/api/src/plugins/hooks.ts | 131 ++++++ packages/api/src/plugins/index.ts | 9 + packages/api/src/plugins/load.spec.ts | 287 ++++++++++++ packages/api/src/plugins/load.ts | 218 +++++++++ packages/api/src/plugins/manifest.spec.ts | 125 +++++ packages/api/src/plugins/manifest.ts | 160 +++++++ packages/api/src/plugins/mcp.spec.ts | 345 ++++++++++++++ packages/api/src/plugins/mcp.ts | 427 ++++++++++++++++++ packages/api/src/plugins/paths.ts | 70 +++ packages/api/src/plugins/skills.ts | 132 ++++++ packages/api/src/plugins/types.ts | 94 ++++ packages/api/src/skills/deployment.ts | 76 +++- packages/api/src/skills/sync/github.ts | 11 +- packages/api/src/utils/env.ts | 30 +- packages/api/src/utils/pluginEnv.spec.ts | 98 ++++ packages/data-provider/src/mcp.ts | 5 + 29 files changed, 2939 insertions(+), 34 deletions(-) create mode 100644 api/server/services/initializeMCPs.plugins.spec.js create mode 100644 packages/api/src/plugins/constants.ts create mode 100644 packages/api/src/plugins/deployment.spec.ts create mode 100644 packages/api/src/plugins/deployment.ts create mode 100644 packages/api/src/plugins/hooks.ts create mode 100644 packages/api/src/plugins/index.ts create mode 100644 packages/api/src/plugins/load.spec.ts create mode 100644 packages/api/src/plugins/load.ts create mode 100644 packages/api/src/plugins/manifest.spec.ts create mode 100644 packages/api/src/plugins/manifest.ts create mode 100644 packages/api/src/plugins/mcp.spec.ts create mode 100644 packages/api/src/plugins/mcp.ts create mode 100644 packages/api/src/plugins/paths.ts create mode 100644 packages/api/src/plugins/skills.ts create mode 100644 packages/api/src/plugins/types.ts create mode 100644 packages/api/src/utils/pluginEnv.spec.ts diff --git a/api/server/index.js b/api/server/index.js index 028ec7a824..33473a8538 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -27,6 +27,8 @@ const { agentStartupTelemetryMiddleware, initializeFileStorage, initializeDeploymentSkills, + initializeDeploymentPlugins, + getDeploymentPluginSkills, loadToolApprovalHooks, maybeInjectQueryDevtoolsBootstrap, preAuthTenantMiddleware, @@ -152,7 +154,12 @@ const startServer = async () => { }); const appConfig = await getAppConfig({ baseOnly: true }); initializeFileStorage(appConfig); - await initializeDeploymentSkills({ projectRoot: path.resolve(__dirname, '../..') }); + const projectRoot = path.resolve(__dirname, '../..'); + await initializeDeploymentPlugins({ projectRoot }); + await initializeDeploymentSkills({ + projectRoot, + additionalSkills: getDeploymentPluginSkills(), + }); initializeGitHubSkillSync(appConfig); startExpiredFileSweep({ appConfig, loadAppConfig: getAppConfig }); // Register any programmatic tool-approval policy hooks declared in diff --git a/api/server/services/initializeMCPs.js b/api/server/services/initializeMCPs.js index 130bde998a..d8bac4b91f 100644 --- a/api/server/services/initializeMCPs.js +++ b/api/server/services/initializeMCPs.js @@ -3,6 +3,7 @@ const { logger } = require('@librechat/data-schemas'); const { registerShutdownTask, setMCPToolsChangedHandler, + getDeploymentPluginMcpServers, setMCPToolsChangedGenerationHandler, setMCPToolsChangedGenerationRenewalHandler, setMCPToolsChangedRevisionHandler, @@ -61,12 +62,43 @@ async function refreshChangedServerTools({ ); } +/** + * Merges Agent Plugins MCP servers under the configured servers. A plugin never + * displaces a server the operator declared in `librechat.yaml`. + */ +function withPluginServers(configured) { + const pluginServers = getDeploymentPluginMcpServers(); + const names = Object.keys(pluginServers); + if (names.length === 0) { + return configured; + } + + const merged = { ...configured }; + for (const name of names) { + /** Own-property check: an inherited member like `toString` is not a conflict. */ + if (Object.hasOwn(merged, name)) { + logger.warn( + `[MCP] Plugin server "${name}" conflicts with a configured server and was skipped.`, + ); + continue; + } + /** Defined rather than assigned so a name like `__proto__` cannot reach a setter. */ + Object.defineProperty(merged, name, { + value: pluginServers[name], + enumerable: true, + writable: true, + configurable: true, + }); + } + return merged; +} + /** * Initialize MCP servers */ async function initializeMCPs() { const appConfig = await getAppConfig({ baseOnly: true }); - const mcpServers = appConfig.mcpConfig; + const mcpServers = withPluginServers(appConfig.mcpConfig); try { createMCPServersRegistry( diff --git a/api/server/services/initializeMCPs.plugins.spec.js b/api/server/services/initializeMCPs.plugins.spec.js new file mode 100644 index 0000000000..265ad38006 --- /dev/null +++ b/api/server/services/initializeMCPs.plugins.spec.js @@ -0,0 +1,169 @@ +/** + * Tests for merging Agent Plugins MCP servers into the configured servers. + * + * Plugin packages are third-party data, so a plugin must never displace a server + * the operator declared in librechat.yaml, and a plugin-controlled server name + * must never reach a prototype setter. + */ + +jest.mock('mongoose', () => ({ + connection: { readyState: 1 }, +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }, +})); + +const mockGetDeploymentPluginMcpServers = jest.fn(); +const mockRegisterShutdownTask = jest.fn(); +const mockSetHandler = jest.fn(); + +jest.mock('@librechat/api', () => ({ + get getDeploymentPluginMcpServers() { + return mockGetDeploymentPluginMcpServers; + }, + get registerShutdownTask() { + return mockRegisterShutdownTask; + }, + get setMCPToolsChangedHandler() { + return mockSetHandler; + }, + get setMCPToolsChangedGenerationHandler() { + return mockSetHandler; + }, + get setMCPToolsChangedGenerationRenewalHandler() { + return mockSetHandler; + }, + get setMCPToolsChangedRevisionHandler() { + return mockSetHandler; + }, +})); + +jest.mock('./Config/mcp', () => ({ + getMCPToolsCacheGeneration: jest.fn(), + renewMCPToolsCacheGeneration: jest.fn(), + getNextAppToolsPublicationRevision: jest.fn(), + updateMCPServerTools: jest.fn(), +})); + +const mockGetAppConfig = jest.fn(); +const mockMergeAppTools = jest.fn(); +const mockSyncStaticTools = jest.fn(); + +jest.mock('./Config', () => ({ + get getAppConfig() { + return mockGetAppConfig; + }, + get mergeAppTools() { + return mockMergeAppTools; + }, + get syncStaticTools() { + return mockSyncStaticTools; + }, +})); + +const mockCreateMCPServersRegistry = jest.fn(); +const mockCreateMCPManager = jest.fn(); +const mockMCPManagerInstance = { + getAppToolFunctions: jest.fn(), + connectAppServers: jest.fn(), + disconnectAppServers: jest.fn(), +}; + +jest.mock('~/config', () => ({ + get createMCPServersRegistry() { + return mockCreateMCPServersRegistry; + }, + get createMCPManager() { + return mockCreateMCPManager; + }, +})); + +const { logger } = require('@librechat/data-schemas'); +const initializeMCPs = require('./initializeMCPs'); + +const pluginServer = { type: 'streamable-http', url: 'https://plugin.example.com/mcp' }; + +describe('initializeMCPs plugin server merge', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCreateMCPServersRegistry.mockReturnValue(undefined); + mockCreateMCPManager.mockResolvedValue(mockMCPManagerInstance); + mockMCPManagerInstance.getAppToolFunctions.mockResolvedValue({}); + mockMergeAppTools.mockResolvedValue(undefined); + mockSyncStaticTools.mockResolvedValue(undefined); + mockMCPManagerInstance.connectAppServers.mockResolvedValue(undefined); + mockGetDeploymentPluginMcpServers.mockReturnValue({}); + }); + + /** Returns the config object the manager was constructed with. */ + function managerConfig() { + return mockCreateMCPManager.mock.calls[0][0]; + } + + it('adds plugin servers alongside configured ones', async () => { + mockGetAppConfig.mockResolvedValue({ mcpConfig: { yamlServer: { type: 'stdio' } } }); + mockGetDeploymentPluginMcpServers.mockReturnValue({ pluginServer }); + + await initializeMCPs(); + + expect(Object.keys(managerConfig()).sort()).toEqual(['pluginServer', 'yamlServer']); + }); + + it('leaves the configured servers untouched when no plugins contribute', async () => { + const mcpConfig = { yamlServer: { type: 'stdio' } }; + mockGetAppConfig.mockResolvedValue({ mcpConfig }); + + await initializeMCPs(); + + expect(managerConfig()).toBe(mcpConfig); + }); + + it('never displaces a server declared in librechat.yaml', async () => { + const configured = { type: 'stdio', command: 'operator-owned' }; + mockGetAppConfig.mockResolvedValue({ mcpConfig: { shared: configured } }); + mockGetDeploymentPluginMcpServers.mockReturnValue({ shared: pluginServer }); + + await initializeMCPs(); + + expect(managerConfig().shared).toBe(configured); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('conflicts with a configured'), + ); + }); + + it('does not treat an inherited property name as a conflict', async () => { + mockGetAppConfig.mockResolvedValue({ mcpConfig: {} }); + mockGetDeploymentPluginMcpServers.mockReturnValue({ toString: pluginServer }); + + await initializeMCPs(); + + expect(Object.hasOwn(managerConfig(), 'toString')).toBe(true); + expect(managerConfig().toString).toBe(pluginServer); + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('conflicts')); + }); + + it('does not pollute the prototype through a plugin server name', async () => { + mockGetAppConfig.mockResolvedValue({ mcpConfig: {} }); + mockGetDeploymentPluginMcpServers.mockReturnValue({ ['__proto__']: pluginServer }); + + await initializeMCPs(); + + expect({}.polluted).toBeUndefined(); + expect(Object.getPrototypeOf(managerConfig())).toBe(Object.prototype); + }); + + it('tolerates a null mcpConfig while still adding plugin servers', async () => { + mockGetAppConfig.mockResolvedValue({ mcpConfig: null }); + mockGetDeploymentPluginMcpServers.mockReturnValue({ pluginServer }); + + await initializeMCPs(); + + expect(Object.keys(managerConfig())).toEqual(['pluginServer']); + }); +}); diff --git a/api/server/services/initializeMCPs.spec.js b/api/server/services/initializeMCPs.spec.js index 4939cd507c..87f40e47db 100644 --- a/api/server/services/initializeMCPs.spec.js +++ b/api/server/services/initializeMCPs.spec.js @@ -69,11 +69,15 @@ const mockUpdateMCPServerTools = jest.fn(); const mockGetMCPToolsCacheGeneration = jest.fn(); const mockRenewMCPToolsCacheGeneration = jest.fn(); const mockGetNextAppToolsPublicationRevision = jest.fn(); +const mockGetDeploymentPluginMcpServers = jest.fn(() => ({})); jest.mock('@librechat/api', () => ({ get registerShutdownTask() { return mockRegisterShutdownTask; }, + get getDeploymentPluginMcpServers() { + return mockGetDeploymentPluginMcpServers; + }, get setMCPToolsChangedHandler() { return mockSetMCPToolsChangedHandler; }, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index fad99cd525..3aaf7d78e3 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -58,6 +58,8 @@ export * from './projects'; /* Skills */ export * from './skills'; export * from './favorites'; +/* Agent Plugins */ +export * from './plugins'; /* Endpoints */ export * from './endpoints'; /* Files */ diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index bb43719843..5852bcd1ff 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -27,10 +27,10 @@ import { MCPServersRegistry } from './registry/MCPServersRegistry'; import { UserConnectionManager } from './UserConnectionManager'; import { ConnectionsRepository } from './ConnectionsRepository'; import { MCPConnectionFactory } from './MCPConnectionFactory'; +import { processMCPEnv, isPluginSourced } from '~/utils/env'; import { preProcessGraphTokens } from '~/utils/graph'; import { formatToolContent } from './parsers'; import { MCPConnection } from './connection'; -import { processMCPEnv } from '~/utils/env'; function createOboToolCallErrorMessage( logPrefix: string, @@ -514,14 +514,19 @@ Please follow these instructions when using tools from the respective MCP server const ephemeralConnection = !!userId && requiresEphemeralUserConnection(rawConfig); disconnectAfterCall = ephemeralConnection && !requestScopedConnections; - /** Pre-process Graph token placeholders (async) before the synchronous processMCPEnv pass */ - const graphProcessedConfig = isDbSourced - ? (rawConfig as t.MCPOptions) - : await preProcessGraphTokens(rawConfig as t.MCPOptions, { - user, - graphTokenResolver, - scopes: process.env.GRAPH_API_SCOPES, - }); + /** + * Pre-process Graph token placeholders (async) before the synchronous processMCPEnv pass. + * Plugin-sourced configs are excluded for the same reason processMCPEnv excludes them: + * a placeholder a plugin authored must never resolve against the user's Graph token. + */ + const graphProcessedConfig = + isDbSourced || isPluginSourced(rawConfig) + ? (rawConfig as t.MCPOptions) + : await preProcessGraphTokens(rawConfig as t.MCPOptions, { + user, + graphTokenResolver, + scopes: process.env.GRAPH_API_SCOPES, + }); const currentOptions = processMCPEnv({ user, body: requestBody, diff --git a/packages/api/src/mcp/UserConnectionManager.ts b/packages/api/src/mcp/UserConnectionManager.ts index 7eadbf8252..afbc2c4b09 100644 --- a/packages/api/src/mcp/UserConnectionManager.ts +++ b/packages/api/src/mcp/UserConnectionManager.ts @@ -21,11 +21,11 @@ import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry'; import { detectOAuthRequirement, MCPOAuthHandler } from '~/mcp/oauth'; import { ConnectionsRepository } from '~/mcp/ConnectionsRepository'; import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory'; +import { processMCPEnv, isPluginSourced } from '~/utils/env'; import { preProcessGraphTokens } from '~/utils/graph'; import { isMCPDomainAllowed } from '~/auth/domain'; import { PENDING_STALE_MS } from '~/flow/manager'; import { MCPConnection } from './connection'; -import { processMCPEnv } from '~/utils/env'; import { mcpConfig } from './mcpConfig'; type PendingOAuthStart = { @@ -814,13 +814,15 @@ export abstract class UserConnectionManager { graphTokenResolver?: t.UserMCPConnectionOptions['graphTokenResolver']; }): Promise { const dbSourced = isUserSourced(config); - const graphProcessedConfig = dbSourced - ? config - : await preProcessGraphTokens(config, { - user, - graphTokenResolver, - scopes: process.env.GRAPH_API_SCOPES, - }); + /** Plugin-authored placeholders must never resolve against the user's Graph token. */ + const graphProcessedConfig = + dbSourced || isPluginSourced(config) + ? config + : await preProcessGraphTokens(config, { + user, + graphTokenResolver, + scopes: process.env.GRAPH_API_SCOPES, + }); return processMCPEnv({ user, diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index cafbe0091d..89e082ef5c 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -37,6 +37,8 @@ jest.mock('~/mcp/oauth', () => ({ jest.mock('~/utils/env', () => ({ processMCPEnv: jest.fn((params) => params.options), + MCP_PLUGIN_SOURCE: 'plugin', + isPluginSourced: jest.fn((config) => config?.source === 'plugin'), })); jest.mock('~/auth/domain', () => ({ diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 56e5dff936..65c3d74fd0 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -1583,6 +1583,7 @@ export class MCPConnection extends EventEmitter { // workaround bug of mcp sdk that can't pass env: // https://github.com/modelcontextprotocol/typescript-sdk/issues/216 env: { ...getDefaultEnvironment(), ...(options.env ?? {}) }, + ...(options.cwd !== undefined && { cwd: options.cwd }), }); case 'websocket': { diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 852dc0b837..c81e31d48b 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -152,7 +152,7 @@ export type FormattedToolResponse = FormattedContentResult; * - `'config'` — admin-defined via Config override, full trust, lazy init * - `'user'` — user-provided via UI, sandboxed (restricted placeholder resolution) */ -export type MCPServerSource = 'yaml' | 'config' | 'user'; +export type MCPServerSource = 'yaml' | 'config' | 'user' | 'plugin'; export type ParsedServerConfig = MCPOptions & { url?: string; diff --git a/packages/api/src/plugins/constants.ts b/packages/api/src/plugins/constants.ts new file mode 100644 index 0000000000..3ed7b66147 --- /dev/null +++ b/packages/api/src/plugins/constants.ts @@ -0,0 +1,28 @@ +/** Agent Plugins specification version implemented by this client. */ +export const AGENT_PLUGINS_VERSION = '1.0.0'; + +export const PLUGIN_MANIFEST_SCHEMA_ID = + 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; +export const PLUGIN_MCP_SCHEMA_ID = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json'; + +export const PLUGIN_MANIFEST_FILE = 'plugin.json'; +export const PLUGIN_MCP_FILE = 'mcp.json'; +export const PLUGIN_SKILLS_DIR = 'skills'; +export const SKILL_MANIFEST_FILE = 'SKILL.md'; + +/** + * LibreChat's reverse-domain extension namespace. Owns both the `extensions` + * manifest key and the top-level extension directory of the same name. + */ +export const LIBRECHAT_EXTENSION_NAMESPACE = 'ai.librechat'; +export const EXTENSION_HOOKS_FILE = 'hooks/hooks.json'; + +export const PLUGIN_ROOT_VAR = 'PLUGIN_ROOT'; +export const PLUGIN_DATA_VAR = 'PLUGIN_DATA'; + +export const DEPLOYMENT_PLUGINS_DIR_ENV = 'DEPLOYMENT_PLUGINS_DIR'; +export const DEFAULT_DEPLOYMENT_PLUGINS_DIR = 'plugin'; +export const DEPLOYMENT_PLUGIN_DATA_DIR_ENV = 'DEPLOYMENT_PLUGIN_DATA_DIR'; +export const DEFAULT_DEPLOYMENT_PLUGIN_DATA_DIR = 'data/plugins'; + +export const MAX_PLUGIN_NAME_LENGTH = 64; diff --git a/packages/api/src/plugins/deployment.spec.ts b/packages/api/src/plugins/deployment.spec.ts new file mode 100644 index 0000000000..a08bfc57f0 --- /dev/null +++ b/packages/api/src/plugins/deployment.spec.ts @@ -0,0 +1,197 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + PLUGIN_MCP_SCHEMA_ID, + PLUGIN_MANIFEST_SCHEMA_ID, + DEPLOYMENT_PLUGINS_DIR_ENV, +} from './constants'; +import { loadPluginsFromDirectory, resolveDeploymentPluginDirectory } from './deployment'; + +let base: string; +let pluginsDir: string; + +function skillDocument(name: string): string { + return `---\nname: ${name}\ndescription: ${name} documents for the user on request.\n---\n\n# ${name}\n\nSteps to follow.\n`; +} + +async function writePlugin( + directoryName: string, + files: Record, + manifest: Record | null = {}, +): Promise { + const root = path.join(pluginsDir, directoryName); + await fs.promises.mkdir(root, { recursive: true }); + if (manifest !== null) { + files['plugin.json'] = JSON.stringify({ + $schema: PLUGIN_MANIFEST_SCHEMA_ID, + name: directoryName, + ...manifest, + }); + } + for (const [relativePath, contents] of Object.entries(files)) { + const target = path.join(root, relativePath); + await fs.promises.mkdir(path.dirname(target), { recursive: true }); + await fs.promises.writeFile(target, contents); + } +} + +function mcpDocument(servers: Record): string { + return JSON.stringify({ $schema: PLUGIN_MCP_SCHEMA_ID, mcpServers: servers }); +} + +function load() { + return loadPluginsFromDirectory(pluginsDir, { projectRoot: base, env: {} }); +} + +beforeEach(async () => { + base = await fs.promises.realpath( + await fs.promises.mkdtemp(path.join(os.tmpdir(), 'lc-plugin-deploy-')), + ); + pluginsDir = path.join(base, 'plugin'); + await fs.promises.mkdir(pluginsDir, { recursive: true }); +}); + +afterEach(async () => { + await fs.promises.rm(base, { recursive: true, force: true }); +}); + +describe('resolveDeploymentPluginDirectory', () => { + it('defaults to plugin/ under the project root', () => { + const resolved = resolveDeploymentPluginDirectory({ projectRoot: '/srv/app', env: {} }); + expect(resolved).toEqual({ directory: '/srv/app/plugin', explicitlyConfigured: false }); + }); + + it('honors an absolute configured directory', () => { + const resolved = resolveDeploymentPluginDirectory({ + projectRoot: '/srv/app', + env: { [DEPLOYMENT_PLUGINS_DIR_ENV]: '/mnt/plugins' }, + }); + expect(resolved).toEqual({ directory: '/mnt/plugins', explicitlyConfigured: true }); + }); +}); + +describe('loadPluginsFromDirectory', () => { + it('returns an empty registry when the directory is absent and unconfigured', async () => { + const registry = await loadPluginsFromDirectory(path.join(base, 'missing'), { env: {} }); + expect(registry.list()).toHaveLength(0); + }); + + it('throws when an explicitly configured directory is absent', async () => { + await expect( + loadPluginsFromDirectory(path.join(base, 'missing'), { + env: {}, + explicitlyConfigured: true, + }), + ).rejects.toThrow(/could not be read/); + }); + + it('loads each immediate child directory as one plugin', async () => { + await writePlugin('alpha', { 'skills/alpha-skill/SKILL.md': skillDocument('alpha-skill') }); + await writePlugin('beta', { + 'mcp.json': mcpDocument({ beta: { type: 'streamable-http', url: 'https://b.example/mcp' } }), + }); + + const registry = await load(); + expect(registry.list().map((plugin) => plugin.manifest.name)).toEqual(['alpha', 'beta']); + expect(registry.skills().map((skill) => skill.name)).toEqual(['alpha-skill']); + expect(Object.keys(registry.mcpServers())).toEqual(['beta']); + }); + + it('creates a persistent data directory per plugin', async () => { + await writePlugin('alpha', {}); + const registry = await load(); + const dataDirectory = registry.list()[0].dataDirectory; + expect(dataDirectory).toBe(path.join(base, 'data/plugins', 'alpha')); + expect((await fs.promises.stat(dataDirectory)).isDirectory()).toBe(true); + }); + + it('skips a rejected plugin and keeps the rest', async () => { + await writePlugin('broken', { 'plugin.json': '{ not json' }, null); + await writePlugin('good', { 'skills/good-skill/SKILL.md': skillDocument('good-skill') }); + + const registry = await load(); + expect(registry.list().map((plugin) => plugin.manifest.name)).toEqual(['good']); + expect(registry.diagnostics().map((issue) => issue.code)).toContain('manifest_invalid_json'); + }); + + it('ignores loose files beside the plugin directories', async () => { + await fs.promises.writeFile(path.join(pluginsDir, 'README.md'), 'not a plugin'); + await writePlugin('alpha', {}); + const registry = await load(); + expect(registry.list()).toHaveLength(1); + }); + + it('isolates a plugin whose data directory cannot be created', async () => { + const dataRoot = path.join(base, 'data/plugins'); + await fs.promises.mkdir(dataRoot, { recursive: true }); + await fs.promises.writeFile(path.join(dataRoot, 'alpha'), 'occupied by a file'); + await writePlugin('alpha', {}); + await writePlugin('beta', { 'skills/beta-skill/SKILL.md': skillDocument('beta-skill') }); + + const registry = await load(); + expect(registry.list().map((plugin) => plugin.manifest.name)).toEqual(['beta']); + expect(registry.skills().map((skill) => skill.name)).toEqual(['beta-skill']); + expect(registry.diagnostics().map((issue) => issue.code)).toContain( + 'data_directory_unavailable', + ); + }); + + it('identifies a rejected plugin by its directory', async () => { + await writePlugin('broken-one', { 'plugin.json': '{ not json' }, null); + await writePlugin('broken-two', { 'plugin.json': '{ also not json' }, null); + + const registry = await load(); + const locations = registry.diagnostics().map((issue) => issue.location); + expect(locations).toContain(path.join(pluginsDir, 'broken-one') + '/plugin.json'); + expect(locations).toContain(path.join(pluginsDir, 'broken-two') + '/plugin.json'); + }); + + describe('name conflicts', () => { + it('refuses a second plugin claiming the same manifest name', async () => { + await writePlugin('first', {}, { name: 'shared-name' }); + await writePlugin('second', {}, { name: 'shared-name' }); + + const registry = await load(); + expect(registry.list()).toHaveLength(1); + expect(registry.diagnostics().map((issue) => issue.code)).toContain('manifest_name_conflict'); + }); + + it('does not let a refused duplicate contribute components', async () => { + await writePlugin( + 'first', + { 'skills/from-first/SKILL.md': skillDocument('from-first') }, + { name: 'shared-name' }, + ); + await writePlugin( + 'second', + { 'skills/from-second/SKILL.md': skillDocument('from-second') }, + { name: 'shared-name' }, + ); + + const registry = await load(); + expect(registry.skills().map((skill) => skill.name)).toEqual(['from-first']); + }); + + it('keeps the first skill and reports the duplicate', async () => { + await writePlugin('alpha', { 'skills/shared/SKILL.md': skillDocument('shared') }); + await writePlugin('beta', { 'skills/shared/SKILL.md': skillDocument('shared') }); + + const registry = await load(); + expect(registry.skills()).toHaveLength(1); + expect(registry.diagnostics().map((issue) => issue.message)).toContainEqual( + expect.stringContaining('already provided by another plugin'), + ); + }); + + it('keeps the first MCP server and reports the duplicate', async () => { + const server = { github: { type: 'streamable-http', url: 'https://a.example/mcp' } }; + await writePlugin('alpha', { 'mcp.json': mcpDocument(server) }); + await writePlugin('beta', { 'mcp.json': mcpDocument(server) }); + + const registry = await load(); + expect(Object.keys(registry.mcpServers())).toEqual(['github']); + expect(registry.diagnostics().map((issue) => issue.code)).toContain('mcp_server_invalid'); + }); + }); +}); diff --git a/packages/api/src/plugins/deployment.ts b/packages/api/src/plugins/deployment.ts new file mode 100644 index 0000000000..1cab0d80f2 --- /dev/null +++ b/packages/api/src/plugins/deployment.ts @@ -0,0 +1,266 @@ +import fs from 'fs'; +import path from 'path'; +import { logger } from '@librechat/data-schemas'; +import type { MCPOptions } from 'librechat-data-provider'; +import type { LoadedPlugin, PluginDiagnostic } from './types'; +import type { PluginHookCapabilities } from '~/agents/hooks'; +import type { DeploymentSkill } from '~/skills'; +import { + DEPLOYMENT_PLUGINS_DIR_ENV, + DEFAULT_DEPLOYMENT_PLUGINS_DIR, + DEPLOYMENT_PLUGIN_DATA_DIR_ENV, + DEFAULT_DEPLOYMENT_PLUGIN_DATA_DIR, +} from './constants'; +import { loadPlugin } from './load'; + +export interface DeploymentPluginOptions { + projectRoot?: string; + env?: NodeJS.ProcessEnv; + hookCapabilities?: PluginHookCapabilities; +} + +interface DirectoryResolution { + directory: string; + explicitlyConfigured: boolean; +} + +function resolveDirectory( + envKey: string, + fallback: string, + options: DeploymentPluginOptions, +): DirectoryResolution { + const env = options.env ?? process.env; + const projectRoot = options.projectRoot ?? process.cwd(); + const configured = env[envKey]?.trim(); + const raw = configured && configured.length > 0 ? configured : fallback; + return { + directory: path.isAbsolute(raw) ? raw : path.resolve(projectRoot, raw), + explicitlyConfigured: configured != null && configured.length > 0, + }; +} + +export function resolveDeploymentPluginDirectory( + options: DeploymentPluginOptions = {}, +): DirectoryResolution { + return resolveDirectory(DEPLOYMENT_PLUGINS_DIR_ENV, DEFAULT_DEPLOYMENT_PLUGINS_DIR, options); +} + +export function resolveDeploymentPluginDataDirectory( + options: DeploymentPluginOptions = {}, +): string { + return resolveDirectory( + DEPLOYMENT_PLUGIN_DATA_DIR_ENV, + DEFAULT_DEPLOYMENT_PLUGIN_DATA_DIR, + options, + ).directory; +} + +/** + * Holds the plugins installed by the operator. Names are unique across the + * registry: a later plugin declaring an already-claimed plugin name, skill + * name, or MCP server name loses and the conflict is reported. + */ +export class DeploymentPluginRegistry { + private readonly plugins: LoadedPlugin[] = []; + private readonly pluginsByName = new Map(); + private readonly skillsByName = new Map(); + private readonly serversByName = new Map(); + private readonly rejections: PluginDiagnostic[] = []; + + constructor( + private readonly directory: string | null, + loaded: LoadedPlugin[] = [], + ) { + for (const plugin of loaded) { + this.add(plugin); + } + } + + private add(plugin: LoadedPlugin): void { + /** + * `PLUGIN_DATA` is derived from the manifest name, so two packages claiming + * one name would share a persistent directory and overwrite each other's + * state. The later package is refused before any of its components land. + */ + const claimed = this.pluginsByName.get(plugin.manifest.name); + if (claimed !== undefined) { + this.rejections.push({ + code: 'manifest_name_conflict', + severity: 'error', + message: `Plugin name "${plugin.manifest.name}" is already claimed by ${claimed.root}; this package was skipped`, + location: plugin.root, + }); + return; + } + this.pluginsByName.set(plugin.manifest.name, plugin); + + for (const skill of plugin.skills) { + if (this.skillsByName.has(skill.name)) { + plugin.diagnostics.push({ + code: 'skill_invalid', + severity: 'warning', + message: `Skill "${skill.name}" is already provided by another plugin and was skipped`, + location: `${plugin.manifest.name}/skills/${skill.name}`, + }); + continue; + } + this.skillsByName.set(skill.name, skill); + } + + for (const server of plugin.mcpServers) { + if (this.serversByName.has(server.name)) { + plugin.diagnostics.push({ + code: 'mcp_server_invalid', + severity: 'warning', + message: `MCP server "${server.name}" is already provided by another plugin and was skipped`, + location: `${plugin.manifest.name}/mcp.json`, + }); + continue; + } + this.serversByName.set(server.name, server.options); + } + + this.plugins.push(plugin); + } + + addRejection(diagnostics: PluginDiagnostic[]): void { + this.rejections.push(...diagnostics); + } + + getDirectory(): string | null { + return this.directory; + } + + list(): LoadedPlugin[] { + return this.plugins; + } + + skills(): DeploymentSkill[] { + return Array.from(this.skillsByName.values()); + } + + /** Plugin MCP servers keyed by server name, shaped for `appConfig.mcpConfig`. */ + mcpServers(): Record { + return Object.fromEntries(this.serversByName); + } + + diagnostics(): PluginDiagnostic[] { + return [...this.rejections, ...this.plugins.flatMap((plugin) => plugin.diagnostics)]; + } +} + +let registry = new DeploymentPluginRegistry(null, []); + +export function getDeploymentPluginRegistry(): DeploymentPluginRegistry { + return registry; +} + +export function getDeploymentPluginSkills(): DeploymentSkill[] { + return registry.skills(); +} + +export function getDeploymentPluginMcpServers(): Record { + return registry.mcpServers(); +} + +/** + * Scans a directory of Agent Plugins packages. Each immediate child directory + * is one plugin; a rejected plugin is reported and skipped so the remaining + * packages still load. + */ +export async function loadPluginsFromDirectory( + directory: string, + options: DeploymentPluginOptions & { explicitlyConfigured?: boolean } = {}, +): Promise { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(directory, { withFileTypes: true }); + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code === 'ENOENT' && + options.explicitlyConfigured !== true + ) { + return new DeploymentPluginRegistry(directory, []); + } + throw new Error(`Deployment plugins directory could not be read: ${directory}`); + } + + const dataRoot = resolveDeploymentPluginDataDirectory(options); + await fs.promises.mkdir(dataRoot, { recursive: true }); + + const candidates = entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => path.join(directory, entry.name)) + .sort(); + + const results = await Promise.all( + candidates.map((candidate) => + loadPlugin(candidate, { + dataRoot, + ...(options.hookCapabilities !== undefined && { + hookCapabilities: options.hookCapabilities, + }), + }), + ), + ); + + const loaded: LoadedPlugin[] = []; + const rejections: PluginDiagnostic[] = []; + for (const result of results) { + if (result.status === 'loaded') { + loaded.push(result.plugin); + continue; + } + /** + * A rejected package has no manifest name to identify it, so its directory + * is the only thing that tells an operator which one to fix. + */ + for (const diagnostic of result.diagnostics) { + rejections.push({ + ...diagnostic, + location: + diagnostic.location === undefined ? result.root : `${result.root}/${diagnostic.location}`, + }); + } + } + + const nextRegistry = new DeploymentPluginRegistry(directory, loaded); + nextRegistry.addRejection(rejections); + return nextRegistry; +} + +function reportDiagnostics(current: DeploymentPluginRegistry): void { + for (const diagnostic of current.diagnostics()) { + const location = diagnostic.location === undefined ? '' : ` (${diagnostic.location})`; + const message = `[agentPlugins] ${diagnostic.message}${location}`; + if (diagnostic.severity === 'error') { + logger.error(message); + continue; + } + logger.warn(message); + } +} + +export async function initializeDeploymentPlugins( + options: DeploymentPluginOptions = {}, +): Promise { + const resolved = resolveDeploymentPluginDirectory(options); + registry = await loadPluginsFromDirectory(resolved.directory, { + ...options, + explicitlyConfigured: resolved.explicitlyConfigured, + }); + reportDiagnostics(registry); + + const count = registry.list().length; + if (count === 0) { + logger.debug(`[agentPlugins] No plugins loaded from ${resolved.directory}`); + return registry; + } + + const skillCount = registry.skills().length; + const serverCount = Object.keys(registry.mcpServers()).length; + logger.info( + `[agentPlugins] Loaded ${count} plugin(s) from ${resolved.directory}: ${skillCount} skill(s), ${serverCount} MCP server(s)`, + ); + return registry; +} diff --git a/packages/api/src/plugins/hooks.ts b/packages/api/src/plugins/hooks.ts new file mode 100644 index 0000000000..943ab18aac --- /dev/null +++ b/packages/api/src/plugins/hooks.ts @@ -0,0 +1,131 @@ +import fs from 'fs'; +import type { PluginDiagnostic, PluginHookContribution } from './types'; +import type { PluginHookCapabilities } from '~/agents/hooks'; +import { EXTENSION_HOOKS_FILE, LIBRECHAT_EXTENSION_NAMESPACE } from './constants'; +import { parsePluginHooks, planPluginHooks } from '~/agents/hooks'; +import { resolveWithinRoot } from './paths'; + +export interface PluginHooksResult { + hooks?: PluginHookContribution; + diagnostics: PluginDiagnostic[]; +} + +/** + * Reports a package that declares hooks when the host has registered no hook + * capabilities. Nothing executes plugin hooks yet, and silently ignoring the + * document would leave an operator believing it runs. + */ +export async function reportUnexecutedHooks(realRoot: string): Promise { + const location = `${LIBRECHAT_EXTENSION_NAMESPACE}/${EXTENSION_HOOKS_FILE}`; + const hooksPath = await resolveWithinRoot(realRoot, location); + if (hooksPath === null) { + return []; + } + try { + await fs.promises.access(hooksPath); + } catch { + return []; + } + return [ + { + code: 'hooks_unsupported', + severity: 'warning', + message: + 'This plugin declares hooks, but LibreChat does not execute plugin hooks yet; the document was ignored', + location, + }, + ]; +} + +/** + * Hooks are outside the portable Agent Plugins v1 format, so LibreChat reads + * them from its own extension directory (§8.2). Absence is normal; a malformed + * document disables only this plugin's hooks. + */ +export async function loadPluginHooks( + realRoot: string, + capabilities: PluginHookCapabilities, +): Promise { + const location = `${LIBRECHAT_EXTENSION_NAMESPACE}/${EXTENSION_HOOKS_FILE}`; + const hooksPath = await resolveWithinRoot(realRoot, location); + if (hooksPath === null) { + return { + diagnostics: [ + { + code: 'path_escape', + severity: 'warning', + message: 'The extension hooks document resolves outside the plugin root', + location, + }, + ], + }; + } + + let raw: string; + try { + raw = await fs.promises.readFile(hooksPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { diagnostics: [] }; + } + return { + diagnostics: [ + { + code: 'hooks_invalid', + severity: 'warning', + message: `Extension hooks document could not be read: ${ + error instanceof Error ? error.message : String(error) + }`, + location, + }, + ], + }; + } + + let document: unknown; + try { + document = JSON.parse(raw); + } catch (error) { + return { + diagnostics: [ + { + code: 'hooks_invalid', + severity: 'warning', + message: `Extension hooks document is not valid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + location, + }, + ], + }; + } + + const parsed = parsePluginHooks(document); + if (!parsed.success) { + return { + diagnostics: [ + { + code: 'hooks_invalid', + severity: 'warning', + message: parsed.issues + .map((issue) => `${issue.path || 'hooks'}: ${issue.message}`) + .join('; '), + location, + }, + ], + }; + } + + const plan = planPluginHooks(parsed.document, capabilities); + const diagnostics: PluginDiagnostic[] = []; + if (plan.summary.unsupported > 0) { + diagnostics.push({ + code: 'hooks_unsupported', + severity: 'warning', + message: `${plan.summary.unsupported} of ${plan.summary.declared} hook declaration(s) are unsupported and will not run`, + location, + }); + } + + return { hooks: { plan, location }, diagnostics }; +} diff --git a/packages/api/src/plugins/index.ts b/packages/api/src/plugins/index.ts new file mode 100644 index 0000000000..9de7935543 --- /dev/null +++ b/packages/api/src/plugins/index.ts @@ -0,0 +1,9 @@ +export * from './constants'; +export * from './types'; +export * from './paths'; +export * from './manifest'; +export * from './mcp'; +export * from './skills'; +export * from './hooks'; +export * from './load'; +export * from './deployment'; diff --git a/packages/api/src/plugins/load.spec.ts b/packages/api/src/plugins/load.spec.ts new file mode 100644 index 0000000000..4658d51bf8 --- /dev/null +++ b/packages/api/src/plugins/load.spec.ts @@ -0,0 +1,287 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import type { PluginHookCapabilities } from '~/agents/hooks'; +import { PLUGIN_MANIFEST_SCHEMA_ID, PLUGIN_MCP_SCHEMA_ID } from './constants'; +import { loadPlugin } from './load'; + +const CAPABILITIES: PluginHookCapabilities = { + handlerTypes: new Set(['command'] as const), + translateMatcher: ({ matcher }) => (matcher === 'Bash' ? 'execute_code' : undefined), +}; + +let base: string; +let root: string; +let dataRoot: string; + +function skillDocument(name: string): string { + return `---\nname: ${name}\ndescription: ${name} documents for the user on request.\n---\n\n# ${name}\n\nSteps to follow when the user asks.\n`; +} + +async function write(relativePath: string, contents: string): Promise { + const target = path.join(root, relativePath); + await fs.promises.mkdir(path.dirname(target), { recursive: true }); + await fs.promises.writeFile(target, contents); +} + +async function writeManifest(overrides: Record = {}): Promise { + await write( + 'plugin.json', + JSON.stringify({ $schema: PLUGIN_MANIFEST_SCHEMA_ID, name: 'demo', ...overrides }), + ); +} + +function load() { + return loadPlugin(root, { dataRoot, hookCapabilities: CAPABILITIES }); +} + +beforeEach(async () => { + base = await fs.promises.realpath( + await fs.promises.mkdtemp(path.join(os.tmpdir(), 'lc-plugin-load-')), + ); + root = path.join(base, 'demo'); + dataRoot = path.join(base, 'plugin-data'); + await fs.promises.mkdir(root, { recursive: true }); + await fs.promises.mkdir(dataRoot, { recursive: true }); +}); + +afterEach(async () => { + await fs.promises.rm(base, { recursive: true, force: true }); +}); + +describe('loadPlugin', () => { + it('loads a manifest-only plugin without reporting missing components', async () => { + await writeManifest(); + const result = await load(); + expect(result.status).toBe('loaded'); + if (result.status !== 'loaded') { + return; + } + expect(result.plugin.skills).toHaveLength(0); + expect(result.plugin.mcpServers).toHaveLength(0); + expect(result.plugin.diagnostics).toHaveLength(0); + }); + + it('creates the persistent plugin data directory', async () => { + await writeManifest(); + const result = await load(); + expect(result.status).toBe('loaded'); + if (result.status !== 'loaded') { + return; + } + expect(result.plugin.dataDirectory).toBe(path.join(dataRoot, 'demo')); + expect((await fs.promises.stat(result.plugin.dataDirectory)).isDirectory()).toBe(true); + }); + + it('loads skills, MCP servers, and extension hooks together', async () => { + await writeManifest({ version: '1.2.0' }); + await write('skills/summarize/SKILL.md', skillDocument('summarize')); + await write('skills/summarize/references/checklist.md', '- check\n'); + await write( + 'mcp.json', + JSON.stringify({ + $schema: PLUGIN_MCP_SCHEMA_ID, + mcpServers: { api: { type: 'streamable-http', url: 'https://example.com/mcp' } }, + }), + ); + await write( + 'ai.librechat/hooks/hooks.json', + JSON.stringify({ + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo' }] }] }, + }), + ); + + const result = await load(); + expect(result.status).toBe('loaded'); + if (result.status !== 'loaded') { + return; + } + expect(result.plugin.skills.map((skill) => skill.name)).toEqual(['summarize']); + expect(result.plugin.skills[0].sourceMetadata.plugin).toBe('demo'); + expect(result.plugin.mcpServers.map((server) => server.name)).toEqual(['api']); + expect(result.plugin.hooks?.plan.summary).toMatchObject({ declared: 1, ready: 1 }); + }); + + it('gives plugin skills ids distinct from deployment skills of the same name', async () => { + await writeManifest(); + await write('skills/summarize/SKILL.md', skillDocument('summarize')); + const first = await load(); + + root = path.join(base, 'other'); + await fs.promises.mkdir(root, { recursive: true }); + await writeManifest({ name: 'other' }); + await write('skills/summarize/SKILL.md', skillDocument('summarize')); + const second = await load(); + + expect(first.status === 'loaded' && second.status === 'loaded').toBe(true); + if (first.status !== 'loaded' || second.status !== 'loaded') { + return; + } + expect(first.plugin.skills[0]._id.toString()).not.toBe(second.plugin.skills[0]._id.toString()); + }); + + describe('component isolation', () => { + it('skips an invalid skill and keeps the valid one', async () => { + await writeManifest(); + await write('skills/good/SKILL.md', skillDocument('good')); + await write('skills/broken/SKILL.md', '---\nname: \n---\n'); + const result = await load(); + expect(result.status).toBe('loaded'); + if (result.status !== 'loaded') { + return; + } + expect(result.plugin.skills.map((skill) => skill.name)).toEqual(['good']); + expect(result.plugin.diagnostics.map((issue) => issue.code)).toContain('skill_invalid'); + }); + + it('does not search deeper than the immediate children of skills/', async () => { + await writeManifest(); + await write('skills/group/nested/SKILL.md', skillDocument('nested')); + const result = await load(); + expect(result.status === 'loaded' && result.plugin.skills).toHaveLength(0); + }); + + it('treats a non-directory skills location as an invalid component type', async () => { + await writeManifest(); + await write('skills', 'not a directory'); + const result = await load(); + expect(result.status).toBe('loaded'); + if (result.status !== 'loaded') { + return; + } + expect(result.plugin.diagnostics.map((issue) => issue.code)).toContain( + 'component_location_invalid', + ); + }); + + it('disables MCP but keeps skills when mcp.json is malformed', async () => { + await writeManifest(); + await write('skills/summarize/SKILL.md', skillDocument('summarize')); + await write('mcp.json', '{ not json'); + const result = await load(); + expect(result.status).toBe('loaded'); + if (result.status !== 'loaded') { + return; + } + expect(result.plugin.skills).toHaveLength(1); + expect(result.plugin.mcpServers).toHaveLength(0); + expect(result.plugin.diagnostics.map((issue) => issue.code)).toContain('mcp_invalid_json'); + }); + + it('reports declared hooks as unexecuted when the host registers no capabilities', async () => { + await writeManifest(); + await write( + 'ai.librechat/hooks/hooks.json', + JSON.stringify({ + hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo' }] }] }, + }), + ); + const result = await loadPlugin(root, { dataRoot }); + expect(result.status).toBe('loaded'); + if (result.status !== 'loaded') { + return; + } + expect(result.plugin.hooks).toBeUndefined(); + expect(result.plugin.diagnostics.map((issue) => issue.code)).toContain('hooks_unsupported'); + }); + + it('stays silent about hooks when a plugin declares none', async () => { + await writeManifest(); + const result = await loadPlugin(root, { dataRoot }); + expect(result.status === 'loaded' && result.plugin.diagnostics).toHaveLength(0); + }); + + it('reports a matcher the host cannot translate instead of running it', async () => { + await writeManifest(); + await write( + 'ai.librechat/hooks/hooks.json', + JSON.stringify({ + hooks: { + PreToolUse: [{ matcher: 'Unknown', hooks: [{ type: 'command', command: 'echo' }] }], + }, + }), + ); + const result = await load(); + expect(result.status).toBe('loaded'); + if (result.status !== 'loaded') { + return; + } + expect(result.plugin.hooks?.plan.summary).toMatchObject({ declared: 1, ready: 0 }); + expect(result.plugin.diagnostics.map((issue) => issue.code)).toContain('hooks_unsupported'); + }); + + it('keeps other components when the hooks document is malformed', async () => { + await writeManifest(); + await write('skills/summarize/SKILL.md', skillDocument('summarize')); + await write('ai.librechat/hooks/hooks.json', JSON.stringify({ hooks: { PreToolUse: 'no' } })); + const result = await load(); + expect(result.status).toBe('loaded'); + if (result.status !== 'loaded') { + return; + } + expect(result.plugin.skills).toHaveLength(1); + expect(result.plugin.hooks).toBeUndefined(); + expect(result.plugin.diagnostics.map((issue) => issue.code)).toContain('hooks_invalid'); + }); + }); + + describe('rejection', () => { + it('rejects a plugin with no manifest', async () => { + const result = await load(); + expect(result.status).toBe('rejected'); + expect(result.status === 'rejected' && result.diagnostics[0].code).toBe('manifest_missing'); + }); + + it('rejects a plugin whose manifest is not valid JSON', async () => { + await write('plugin.json', '{ nope'); + const result = await load(); + expect(result.status === 'rejected' && result.diagnostics[0].code).toBe( + 'manifest_invalid_json', + ); + }); + + it('does not load components when the manifest is rejected', async () => { + await write('plugin.json', JSON.stringify({ $schema: PLUGIN_MANIFEST_SCHEMA_ID })); + await write('skills/summarize/SKILL.md', skillDocument('summarize')); + const result = await load(); + expect(result.status).toBe('rejected'); + }); + }); + + describe('path containment', () => { + it('skips a skill whose SKILL.md is a symlink escaping the plugin root', async () => { + await writeManifest(); + const outside = path.join(base, 'outside'); + await fs.promises.mkdir(outside, { recursive: true }); + await fs.promises.writeFile(path.join(outside, 'SKILL.md'), skillDocument('escaped')); + await fs.promises.mkdir(path.join(root, 'skills', 'escaped'), { recursive: true }); + await fs.promises.symlink( + path.join(outside, 'SKILL.md'), + path.join(root, 'skills', 'escaped', 'SKILL.md'), + ); + + const result = await load(); + expect(result.status).toBe('loaded'); + if (result.status !== 'loaded') { + return; + } + expect(result.plugin.skills).toHaveLength(0); + expect(result.plugin.diagnostics.map((issue) => issue.code)).toContain('path_escape'); + }); + + it('accepts a symlink that resolves within the plugin root', async () => { + await writeManifest(); + await write('shared/SKILL.md', skillDocument('shared')); + await fs.promises.mkdir(path.join(root, 'skills', 'shared'), { recursive: true }); + await fs.promises.symlink( + path.join(root, 'shared', 'SKILL.md'), + path.join(root, 'skills', 'shared', 'SKILL.md'), + ); + + const result = await load(); + expect(result.status === 'loaded' && result.plugin.skills.map((s) => s.name)).toEqual([ + 'shared', + ]); + }); + }); +}); diff --git a/packages/api/src/plugins/load.ts b/packages/api/src/plugins/load.ts new file mode 100644 index 0000000000..0ab88a7356 --- /dev/null +++ b/packages/api/src/plugins/load.ts @@ -0,0 +1,218 @@ +import fs from 'fs'; +import path from 'path'; +import type { PluginDiagnostic, PluginLoadResult, PluginManifest } from './types'; +import type { PluginHookCapabilities } from '~/agents/hooks'; +import type { PluginHooksResult } from './hooks'; +import { realpathAllowingMissing, resolveWithinRoot } from './paths'; +import { PLUGIN_MANIFEST_FILE, PLUGIN_MCP_FILE } from './constants'; +import { loadPluginHooks, reportUnexecutedHooks } from './hooks'; +import { readMcpConfig, schemaVersion } from './mcp'; +import { validateManifest } from './manifest'; +import { loadPluginSkills } from './skills'; + +export interface LoadPluginOptions { + /** Root under which each plugin's persistent `PLUGIN_DATA` directory is created. */ + dataRoot: string; + hookCapabilities?: PluginHookCapabilities; +} + +function rejected(root: string, diagnostics: PluginDiagnostic[]): PluginLoadResult { + return { status: 'rejected', root, diagnostics }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function readMcpDocument( + realRoot: string, +): Promise<{ document?: unknown; diagnostics: PluginDiagnostic[] }> { + const mcpPath = await resolveWithinRoot(realRoot, PLUGIN_MCP_FILE); + if (mcpPath === null) { + return { + diagnostics: [ + { + code: 'path_escape', + severity: 'warning', + message: 'mcp.json resolves outside the plugin root; MCP was disabled for this plugin', + location: PLUGIN_MCP_FILE, + }, + ], + }; + } + + let stat: fs.Stats; + try { + stat = await fs.promises.stat(mcpPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { diagnostics: [] }; + } + return { + diagnostics: [ + { + code: 'mcp_unreadable', + severity: 'warning', + message: `mcp.json could not be read: ${errorMessage(error)}`, + location: PLUGIN_MCP_FILE, + }, + ], + }; + } + if (!stat.isFile()) { + return { + diagnostics: [ + { + code: 'component_location_invalid', + severity: 'warning', + message: 'mcp.json is not a regular file; MCP was disabled for this plugin', + location: PLUGIN_MCP_FILE, + }, + ], + }; + } + + try { + return { document: JSON.parse(await fs.promises.readFile(mcpPath, 'utf8')), diagnostics: [] }; + } catch (error) { + return { + diagnostics: [ + { + code: 'mcp_invalid_json', + severity: 'warning', + message: `mcp.json is not valid JSON: ${errorMessage(error)}`, + location: PLUGIN_MCP_FILE, + }, + ], + }; + } +} + +async function readManifest( + realRoot: string, +): Promise<{ manifest?: PluginManifest; diagnostics: PluginDiagnostic[] }> { + const manifestPath = await resolveWithinRoot(realRoot, PLUGIN_MANIFEST_FILE); + if (manifestPath === null) { + return { + diagnostics: [ + { + code: 'path_escape', + severity: 'error', + message: 'plugin.json resolves outside the plugin root', + location: PLUGIN_MANIFEST_FILE, + }, + ], + }; + } + + let raw: string; + try { + raw = await fs.promises.readFile(manifestPath, 'utf8'); + } catch (error) { + const missing = (error as NodeJS.ErrnoException).code === 'ENOENT'; + return { + diagnostics: [ + { + code: missing ? 'manifest_missing' : 'manifest_unreadable', + severity: 'error', + message: missing + ? 'plugin.json was not found in the plugin root' + : `plugin.json could not be read: ${errorMessage(error)}`, + location: PLUGIN_MANIFEST_FILE, + }, + ], + }; + } + + let document: unknown; + try { + document = JSON.parse(raw); + } catch (error) { + return { + diagnostics: [ + { + code: 'manifest_invalid_json', + severity: 'error', + message: `plugin.json is not valid JSON: ${errorMessage(error)}`, + location: PLUGIN_MANIFEST_FILE, + }, + ], + }; + } + + const result = validateManifest(document); + if (result.status === 'rejected') { + return { diagnostics: result.diagnostics }; + } + return { manifest: result.manifest, diagnostics: result.diagnostics }; +} + +/** + * Loads one Agent Plugins package. A manifest failure rejects the plugin; + * every component failure below it is isolated so independently valid + * components still load (§11.3). + */ +export async function loadPlugin( + root: string, + options: LoadPluginOptions, +): Promise { + const realRoot = await realpathAllowingMissing(root); + const { manifest, diagnostics: manifestDiagnostics } = await readManifest(realRoot); + if (manifest === undefined) { + return rejected(realRoot, manifestDiagnostics); + } + + const diagnostics: PluginDiagnostic[] = [...manifestDiagnostics]; + const dataDirectory = path.join(options.dataRoot, manifest.name); + try { + await fs.promises.mkdir(dataDirectory, { recursive: true }); + } catch (error) { + return rejected(realRoot, [ + ...diagnostics, + { + code: 'data_directory_unavailable', + severity: 'error', + message: `The persistent data directory could not be created: ${errorMessage(error)}`, + location: dataDirectory, + }, + ]); + } + const realDataDirectory = await realpathAllowingMissing(dataDirectory); + + const [skillsResult, mcpDocument, hooksResult] = await Promise.all([ + loadPluginSkills(realRoot, manifest.name), + readMcpDocument(realRoot), + options.hookCapabilities === undefined + ? reportUnexecutedHooks(realRoot).then((diagnostics) => ({ diagnostics })) + : loadPluginHooks(realRoot, options.hookCapabilities), + ]); + + diagnostics.push( + ...skillsResult.diagnostics, + ...mcpDocument.diagnostics, + ...hooksResult.diagnostics, + ); + + const mcpResult = + mcpDocument.document === undefined + ? { servers: [], diagnostics: [] } + : await readMcpConfig(mcpDocument.document, { + realRoot, + dataDirectory: realDataDirectory, + declaredVersion: schemaVersion(manifest.$schema) ?? '', + }); + diagnostics.push(...mcpResult.diagnostics); + + return { + status: 'loaded', + plugin: { + root: realRoot, + dataDirectory: realDataDirectory, + manifest, + skills: skillsResult.skills, + mcpServers: mcpResult.servers, + ...(hooksResult.hooks !== undefined && { hooks: hooksResult.hooks }), + diagnostics, + }, + }; +} diff --git a/packages/api/src/plugins/manifest.spec.ts b/packages/api/src/plugins/manifest.spec.ts new file mode 100644 index 0000000000..0a7188550e --- /dev/null +++ b/packages/api/src/plugins/manifest.spec.ts @@ -0,0 +1,125 @@ +import { PLUGIN_MANIFEST_SCHEMA_ID } from './constants'; +import { validateManifest } from './manifest'; + +function manifest(overrides: Record = {}): Record { + return { $schema: PLUGIN_MANIFEST_SCHEMA_ID, name: 'my-plugin', ...overrides }; +} + +describe('validateManifest', () => { + it('accepts a minimal manifest', () => { + const result = validateManifest(manifest()); + expect(result.status).toBe('ok'); + expect(result.diagnostics).toHaveLength(0); + }); + + it('rejects a manifest that is not a JSON object', () => { + for (const document of [[], null, 'plugin', 7]) { + expect(validateManifest(document).status).toBe('rejected'); + } + }); + + describe('$schema selection', () => { + it('rejects a manifest with no $schema', () => { + const result = validateManifest({ name: 'my-plugin' }); + expect(result.status).toBe('rejected'); + expect(result.diagnostics[0].code).toBe('manifest_invalid'); + }); + + it('reports an unsupported specification version', () => { + const result = validateManifest( + manifest({ $schema: 'https://agent-plugins.org/schemas/2.0.0/plugin.schema.json' }), + ); + expect(result.status).toBe('rejected'); + expect(result.diagnostics[0].code).toBe('manifest_unsupported_version'); + }); + }); + + describe('non-fatal exceptions', () => { + it('reports and ignores unknown top-level fields', () => { + const result = validateManifest(manifest({ mcpServers: {}, hooks: [] })); + expect(result.status).toBe('ok'); + expect(result.diagnostics.map((issue) => issue.code)).toEqual([ + 'manifest_unknown_field', + 'manifest_unknown_field', + ]); + expect(result.status === 'ok' && 'mcpServers' in result.manifest).toBe(false); + }); + + it('reports and ignores a non-object extensions field', () => { + const result = validateManifest(manifest({ extensions: 'nope' })); + expect(result.status).toBe('ok'); + expect(result.diagnostics[0].code).toBe('extensions_invalid'); + expect(result.status === 'ok' && result.manifest.extensions).toBeUndefined(); + }); + }); + + describe('name constraints', () => { + it.each(['a', 'my-plugin', 'acme.tools', 'lint3r', 'a1.b2-c3'])('accepts %s', (name) => { + expect(validateManifest(manifest({ name })).status).toBe('ok'); + }); + + it.each([ + ['My-Plugin', 'uppercase'], + ['-start', 'leading hyphen'], + ['end-', 'trailing hyphen'], + ['.start', 'leading period'], + ['has--double', 'consecutive hyphens'], + ['too.many..dots', 'consecutive periods'], + ['', 'empty'], + ['under_score', 'underscore'], + ['a'.repeat(65), 'too long'], + ])('rejects %s (%s)', (name) => { + expect(validateManifest(manifest({ name })).status).toBe('rejected'); + }); + + it('accepts a 64 character name', () => { + expect(validateManifest(manifest({ name: 'a'.repeat(64) })).status).toBe('ok'); + }); + }); + + describe('metadata fields', () => { + it('does not validate metadata semantics', () => { + const result = validateManifest( + manifest({ + version: 'not-semver', + homepage: 'not a url', + repository: 'also not a url', + license: 'Definitely-Not-SPDX', + author: { email: 'not-an-email', url: 'nope' }, + }), + ); + expect(result.status).toBe('ok'); + expect(result.diagnostics).toHaveLength(0); + }); + + it('rejects metadata fields with the wrong JSON type', () => { + expect(validateManifest(manifest({ version: 1 })).status).toBe('rejected'); + expect(validateManifest(manifest({ keywords: 'one' })).status).toBe('rejected'); + expect(validateManifest(manifest({ keywords: [1] })).status).toBe('rejected'); + }); + + it('rejects an author object with unknown or non-string fields', () => { + expect(validateManifest(manifest({ author: { name: 'A', role: 'owner' } })).status).toBe( + 'rejected', + ); + expect(validateManifest(manifest({ author: { name: 7 } })).status).toBe('rejected'); + }); + }); + + describe('extensions', () => { + it('preserves namespace data without validating its contents', () => { + const result = validateManifest( + manifest({ extensions: { 'ai.librechat': { anything: [1, { deep: true }] } } }), + ); + expect(result.status).toBe('ok'); + expect(result.status === 'ok' && result.manifest.extensions).toEqual({ + 'ai.librechat': { anything: [1, { deep: true }] }, + }); + }); + + it('rejects a namespace whose value is not an object', () => { + const result = validateManifest(manifest({ extensions: { 'ai.librechat': true } })); + expect(result.status).toBe('rejected'); + }); + }); +}); diff --git a/packages/api/src/plugins/manifest.ts b/packages/api/src/plugins/manifest.ts new file mode 100644 index 0000000000..a0fb912dba --- /dev/null +++ b/packages/api/src/plugins/manifest.ts @@ -0,0 +1,160 @@ +import { z } from 'zod'; +import type { PluginDiagnostic, PluginExtensionData, PluginManifest } from './types'; +import { MAX_PLUGIN_NAME_LENGTH, PLUGIN_MANIFEST_SCHEMA_ID } from './constants'; + +/** + * Agent Plugins §5.5: 1-64 characters, lowercase alphanumerics with hyphens and + * periods, alphanumeric at both ends, and no `--` or `..` runs. + */ +const PLUGIN_NAME_PATTERN = /^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +const MANIFEST_KEYS = new Set([ + '$schema', + 'name', + 'version', + 'description', + 'author', + 'homepage', + 'repository', + 'license', + 'keywords', + 'extensions', +]); + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const authorSchema = z + .object({ + name: z.string().optional(), + email: z.string().optional(), + url: z.string().optional(), + }) + .strict(); + +/** + * Metadata fields are validated by JSON type only. §5.4 forbids rejecting a + * manifest because `version` is not semver, a URL field is not a URL, or + * `license` is not an SPDX identifier. + */ +const manifestSchema = z + .object({ + $schema: z.string(), + name: z.string().min(1).max(MAX_PLUGIN_NAME_LENGTH).regex(PLUGIN_NAME_PATTERN), + version: z.string().optional(), + description: z.string().optional(), + author: authorSchema.optional(), + homepage: z.string().optional(), + repository: z.string().optional(), + license: z.string().optional(), + keywords: z.array(z.string()).optional(), + }) + .strict(); + +export type ManifestResult = + | { status: 'ok'; manifest: PluginManifest; diagnostics: PluginDiagnostic[] } + | { status: 'rejected'; diagnostics: PluginDiagnostic[] }; + +function rejected( + code: PluginDiagnostic['code'], + message: string, + diagnostics: PluginDiagnostic[] = [], +): ManifestResult { + return { + status: 'rejected', + diagnostics: [...diagnostics, { code, severity: 'error', message }], + }; +} + +/** + * Splits declared extension namespaces from the manifest. A non-object + * `extensions` field is reported and ignored (§8.1), while a non-object value + * for an individual namespace remains a fatal schema violation (§5.2). + */ +function readExtensions( + value: unknown, + diagnostics: PluginDiagnostic[], +): { extensions?: Record; invalidNamespace?: string } { + if (value === undefined) { + return {}; + } + if (!isPlainObject(value)) { + diagnostics.push({ + code: 'extensions_invalid', + severity: 'warning', + message: '"extensions" must be an object; the field was ignored', + }); + return {}; + } + + const extensions: Record = {}; + for (const [namespace, contents] of Object.entries(value)) { + if (!isPlainObject(contents)) { + return { invalidNamespace: namespace }; + } + /** Parsed from JSON, so the contents are JsonValue by construction; §8.1 forbids validating them further. */ + extensions[namespace] = contents as PluginExtensionData; + } + return { extensions }; +} + +/** + * Validates a parsed `plugin.json` against the closed Agent Plugins manifest + * schema. Unknown top-level fields and a non-object `extensions` field are + * reported and ignored; every other violation rejects the plugin. + */ +export function validateManifest(document: unknown): ManifestResult { + if (!isPlainObject(document)) { + return rejected('manifest_invalid', 'plugin.json must contain a top-level JSON object'); + } + + const schemaId = document.$schema; + if (typeof schemaId !== 'string' || schemaId.length === 0) { + return rejected('manifest_invalid', 'plugin.json is missing the required "$schema" field'); + } + if (schemaId !== PLUGIN_MANIFEST_SCHEMA_ID) { + return rejected( + 'manifest_unsupported_version', + `Unsupported Agent Plugins manifest schema "${schemaId}"; this client implements ${PLUGIN_MANIFEST_SCHEMA_ID}`, + ); + } + + const diagnostics: PluginDiagnostic[] = []; + const known: Record = {}; + for (const [key, value] of Object.entries(document)) { + if (!MANIFEST_KEYS.has(key)) { + diagnostics.push({ + code: 'manifest_unknown_field', + severity: 'warning', + message: `"${key}" is not a recognized plugin.json field and was ignored`, + }); + continue; + } + known[key] = value; + } + + const { extensions: extensionsValue, ...coreFields } = known; + const { extensions, invalidNamespace } = readExtensions(extensionsValue, diagnostics); + if (invalidNamespace !== undefined) { + return rejected( + 'manifest_invalid', + `"extensions.${invalidNamespace}" must be an object`, + diagnostics, + ); + } + + const parsed = manifestSchema.safeParse(coreFields); + if (!parsed.success) { + const detail = parsed.error.issues + .map((issue) => `${issue.path.join('.') || 'plugin.json'}: ${issue.message}`) + .join('; '); + return rejected('manifest_invalid', detail, diagnostics); + } + + return { + status: 'ok', + manifest: { ...parsed.data, ...(extensions !== undefined && { extensions }) }, + diagnostics, + }; +} diff --git a/packages/api/src/plugins/mcp.spec.ts b/packages/api/src/plugins/mcp.spec.ts new file mode 100644 index 0000000000..f78e8e9057 --- /dev/null +++ b/packages/api/src/plugins/mcp.spec.ts @@ -0,0 +1,345 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import type { PluginMcpContext } from './mcp'; +import { expandPluginVariables, readMcpConfig } from './mcp'; +import { PLUGIN_MCP_SCHEMA_ID } from './constants'; + +let root: string; +let dataDirectory: string; +let context: PluginMcpContext; + +beforeEach(async () => { + const base = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'lc-plugin-mcp-')); + root = await fs.promises.realpath( + await fs.promises + .mkdir(path.join(base, 'root'), { recursive: true }) + .then(() => path.join(base, 'root')), + ); + dataDirectory = await fs.promises.realpath( + await fs.promises + .mkdir(path.join(base, 'data'), { recursive: true }) + .then(() => path.join(base, 'data')), + ); + context = { realRoot: root, dataDirectory, declaredVersion: '1.0.0' }; +}); + +afterEach(async () => { + await fs.promises.rm(path.dirname(root), { recursive: true, force: true }); +}); + +function document(servers: Record): Record { + return { $schema: PLUGIN_MCP_SCHEMA_ID, mcpServers: servers }; +} + +describe('expandPluginVariables', () => { + it('replaces every exact occurrence in one pass', () => { + expect(expandPluginVariables('${PLUGIN_ROOT}/a:${PLUGIN_DATA}/b', '/r', '/d')).toBe( + '/r/a:/d/b', + ); + }); + + it('does not rescan text introduced by a replacement', () => { + expect(expandPluginVariables('${PLUGIN_ROOT}', '${PLUGIN_DATA}', '/d')).toBe('${PLUGIN_DATA}'); + }); + + it('leaves unrecognized placeholder-like text literal', () => { + expect(expandPluginVariables('${HOME}/${PLUGIN_ROOTX}', '/r', '/d')).toBe( + '${HOME}/${PLUGIN_ROOTX}', + ); + }); + + it('treats replacement values containing $ as literal text', () => { + expect(expandPluginVariables('${PLUGIN_ROOT}/x', '/a$&b', '/d')).toBe('/a$&b/x'); + }); +}); + +describe('readMcpConfig document rules', () => { + it('accepts an empty mcpServers object', async () => { + const result = await readMcpConfig(document({}), context); + expect(result.servers).toHaveLength(0); + expect(result.diagnostics).toHaveLength(0); + }); + + it('disables MCP when a foreign top-level field is present', async () => { + const result = await readMcpConfig({ ...document({}), extra: true }, context); + expect(result.diagnostics[0].code).toBe('mcp_invalid'); + }); + + it('disables MCP for an unsupported schema identifier', async () => { + const result = await readMcpConfig( + { $schema: 'https://agent-plugins.org/schemas/2.0.0/mcp.schema.json', mcpServers: {} }, + context, + ); + expect(result.diagnostics[0].code).toBe('mcp_invalid'); + }); + + it('reports a version mismatch against plugin.json', async () => { + const result = await readMcpConfig(document({}), { ...context, declaredVersion: '1.1.0' }); + expect(result.diagnostics[0].code).toBe('mcp_version_mismatch'); + expect(result.servers).toHaveLength(0); + }); + + it('skips only the invalid entry and keeps the rest', async () => { + const result = await readMcpConfig( + document({ + good: { type: 'stdio', command: 'node' }, + bad: { type: 'carrier-pigeon', url: 'https://example.com' }, + }), + context, + ); + expect(result.servers.map((server) => server.name)).toEqual(['good']); + expect(result.diagnostics[0].code).toBe('mcp_server_invalid'); + }); +}); + +describe('stdio servers', () => { + it('maps a bare command with plugin variables expanded', async () => { + const result = await readMcpConfig( + document({ + db: { + type: 'stdio', + command: 'npx', + args: ['--config', '${PLUGIN_ROOT}/config/db.json'], + env: { DATA_DIR: '${PLUGIN_DATA}/database' }, + }, + }), + context, + ); + expect(result.servers[0].options).toMatchObject({ + type: 'stdio', + command: 'npx', + args: ['--config', `${root}/config/db.json`], + cwd: root, + }); + expect(result.servers[0].options).toHaveProperty('env.DATA_DIR', `${dataDirectory}/database`); + }); + + it('supplies the reserved variables in the subprocess environment', async () => { + const result = await readMcpConfig( + document({ db: { type: 'stdio', command: 'node' } }), + context, + ); + expect(result.servers[0].options).toHaveProperty('env.PLUGIN_ROOT', root); + expect(result.servers[0].options).toHaveProperty('env.PLUGIN_DATA', dataDirectory); + }); + + it('rejects an entry declaring a reserved environment variable', async () => { + const result = await readMcpConfig( + document({ db: { type: 'stdio', command: 'node', env: { PLUGIN_ROOT: '/tmp' } } }), + context, + ); + expect(result.servers).toHaveLength(0); + expect(result.diagnostics[0].message).toContain('reserved'); + }); + + it('resolves a plugin-relative command against the plugin root', async () => { + await fs.promises.mkdir(path.join(root, 'bin'), { recursive: true }); + await fs.promises.writeFile(path.join(root, 'bin', 'server'), '#!/bin/sh\n'); + const result = await readMcpConfig( + document({ local: { type: 'stdio', command: './bin/server' } }), + context, + ); + expect(result.servers[0].options).toHaveProperty('command', path.join(root, 'bin', 'server')); + }); + + it.each([ + ['../bin/server', 'parent-relative'], + ['/usr/bin/server', 'absolute'], + ['bin/server', 'bare relative path'], + ['server --flag', 'shell string'], + ])('rejects the command %s (%s)', async (command) => { + const result = await readMcpConfig(document({ s: { type: 'stdio', command } }), context); + expect(result.servers).toHaveLength(0); + expect(result.diagnostics[0].code).toBe('mcp_server_invalid'); + }); + + it('does not expand placeholders in command', async () => { + const result = await readMcpConfig( + document({ s: { type: 'stdio', command: '${PLUGIN_ROOT}' } }), + context, + ); + expect(result.servers[0].options).toHaveProperty('command', '${PLUGIN_ROOT}'); + }); + + it('accepts each documented cwd form', async () => { + await fs.promises.mkdir(path.join(root, 'work'), { recursive: true }); + await fs.promises.mkdir(path.join(dataDirectory, 'state'), { recursive: true }); + const result = await readMcpConfig( + document({ + a: { type: 'stdio', command: 'node', cwd: './work' }, + b: { type: 'stdio', command: 'node', cwd: '${PLUGIN_ROOT}' }, + c: { type: 'stdio', command: 'node', cwd: '${PLUGIN_DATA}/state' }, + }), + context, + ); + expect(result.servers.map((server) => (server.options as { cwd?: string }).cwd)).toEqual([ + path.join(root, 'work'), + root, + path.join(dataDirectory, 'state'), + ]); + }); + + it.each(['work', '../work', '${PLUGIN_ROOT}/../escape', '${PLUGIN_DATA}/../escape', '/abs'])( + 'rejects the cwd %s', + async (cwd) => { + const result = await readMcpConfig( + document({ s: { type: 'stdio', command: 'node', cwd } }), + context, + ); + expect(result.servers).toHaveLength(0); + }, + ); + + it('rejects a field belonging to another variant', async () => { + const result = await readMcpConfig( + document({ s: { type: 'stdio', command: 'node', url: 'https://example.com' } }), + context, + ); + expect(result.servers).toHaveLength(0); + }); +}); + +describe('provenance and name safety', () => { + it('marks every emitted server as plugin-sourced', async () => { + const result = await readMcpConfig( + document({ + local: { type: 'stdio', command: 'node' }, + remote: { type: 'streamable-http', url: 'https://example.com/mcp' }, + }), + context, + ); + expect(result.servers.map((server) => server.options.source)).toEqual(['plugin', 'plugin']); + }); + + it('rejects a package that tries to declare its own provenance', async () => { + const result = await readMcpConfig( + document({ s: { type: 'stdio', command: 'node', source: 'yaml' } }), + context, + ); + expect(result.servers).toHaveLength(0); + }); + + it.each(['__proto__', 'constructor', 'prototype'])( + 'rejects the reserved name %s', + async (name) => { + const result = await readMcpConfig( + document({ [name]: { type: 'stdio', command: 'node' } }), + context, + ); + expect(result.servers).toHaveLength(0); + expect(result.diagnostics[0].message).toContain('reserved'); + }, + ); + + it('rejects a name that would change under tool-name normalization', async () => { + const result = await readMcpConfig( + document({ 'sales api': { type: 'stdio', command: 'node' } }), + context, + ); + expect(result.servers).toHaveLength(0); + expect(result.diagnostics[0].message).toContain('sales_api'); + }); + + it('keeps normalization-stable names', async () => { + const result = await readMcpConfig( + document({ 'sales-api.v2': { type: 'stdio', command: 'node' } }), + context, + ); + expect(result.servers.map((server) => server.name)).toEqual(['sales-api.v2']); + }); +}); + +describe('remote servers', () => { + it('maps streamable-http and sse entries', async () => { + const result = await readMcpConfig( + document({ + api: { + type: 'streamable-http', + url: 'https://deploy.example.com/mcp', + headers: { 'X-Tenant': 'public' }, + }, + legacy: { type: 'sse', url: 'https://legacy.example.com/sse' }, + }), + context, + ); + expect(result.servers[0].options).toEqual({ + source: 'plugin', + type: 'streamable-http', + url: 'https://deploy.example.com/mcp', + headers: { 'X-Tenant': 'public' }, + }); + expect(result.servers[1].options).toEqual({ + source: 'plugin', + type: 'sse', + url: 'https://legacy.example.com/sse', + }); + }); + + it('allows http only on loopback hosts', async () => { + const result = await readMcpConfig( + document({ + local: { type: 'streamable-http', url: 'http://localhost:3000/mcp' }, + loop4: { type: 'streamable-http', url: 'http://127.0.0.1:3000/mcp' }, + loop6: { type: 'streamable-http', url: 'http://[::1]:3000/mcp' }, + remote: { type: 'streamable-http', url: 'http://example.com/mcp' }, + }), + context, + ); + expect(result.servers.map((server) => server.name)).toEqual(['local', 'loop4', 'loop6']); + expect(result.diagnostics[0].message).toContain('https'); + }); + + it.each([ + ['https://user:pw@example.com/mcp', 'user information'], + ['https://example.com/mcp#frag', 'fragment'], + ['/relative/mcp', 'relative'], + ['ws://example.com/mcp', 'websocket scheme'], + ])('rejects the url %s (%s)', async (url) => { + const result = await readMcpConfig(document({ s: { type: 'streamable-http', url } }), context); + expect(result.servers).toHaveLength(0); + }); + + it('rejects headers repeated under different casing', async () => { + const result = await readMcpConfig( + document({ + s: { + type: 'streamable-http', + url: 'https://example.com/mcp', + headers: { 'X-Tenant': 'a', 'x-tenant': 'b' }, + }, + }), + context, + ); + expect(result.servers).toHaveLength(0); + expect(result.diagnostics[0].message).toContain('casing'); + }); + + it.each<[Record, string]>([ + [{ 'Bad Header': 'v' }, 'invalid name'], + [{ 'X-Tenant': 'line\nbreak' }, 'invalid value'], + ])('rejects malformed headers (%s)', async (headers) => { + const result = await readMcpConfig( + document({ s: { type: 'streamable-http', url: 'https://example.com/mcp', headers } }), + context, + ); + expect(result.servers).toHaveLength(0); + }); + + it('does not expand placeholders in url or headers', async () => { + const result = await readMcpConfig( + document({ + s: { + type: 'streamable-http', + url: 'https://example.com/${PLUGIN_ROOT}', + headers: { 'X-Path': '${PLUGIN_DATA}' }, + }, + }), + context, + ); + expect(result.servers[0].options).toMatchObject({ + url: 'https://example.com/${PLUGIN_ROOT}', + headers: { 'X-Path': '${PLUGIN_DATA}' }, + }); + }); +}); diff --git a/packages/api/src/plugins/mcp.ts b/packages/api/src/plugins/mcp.ts new file mode 100644 index 0000000000..8f99616c61 --- /dev/null +++ b/packages/api/src/plugins/mcp.ts @@ -0,0 +1,427 @@ +import path from 'path'; +import { normalizeServerName } from 'librechat-data-provider'; +import type { PluginDiagnostic, PluginMcpOptions, PluginMcpServer } from './types'; +import { + PLUGIN_MCP_FILE, + PLUGIN_DATA_VAR, + PLUGIN_ROOT_VAR, + PLUGIN_MCP_SCHEMA_ID, +} from './constants'; +import { isPluginRelativePath, isWithinRoot, realpathAllowingMissing } from './paths'; +import { MCP_PLUGIN_SOURCE } from '~/utils/env'; + +const PLACEHOLDER_PATTERN = /\$\{(PLUGIN_ROOT|PLUGIN_DATA)\}/g; +const SCHEMA_VERSION_PATTERN = /\/schemas\/(\d+\.\d+\.\d+)\//; +const HTTP_TOKEN_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +/** RFC 7230 field-value: visible ASCII, space, horizontal tab, and obs-text. CR, LF, and NUL are rejected. */ +const HTTP_FIELD_VALUE_PATTERN = /^[\t\x20-\x7e\x80-\xff]*$/; +const LOOPBACK_IPV4_PATTERN = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + +const SUPPORTED_TRANSPORTS = new Set(['stdio', 'streamable-http', 'sse']); + +const STDIO_FIELDS = new Set(['type', 'command', 'args', 'env', 'cwd']); +const REMOTE_FIELDS = new Set(['type', 'url', 'headers']); + +/** + * Server names become keys on plain configuration objects downstream. These + * survive tool-name normalization unchanged, so they are refused here to keep a + * package from reaching a prototype setter. + */ +const RESERVED_SERVER_NAMES = new Set(['__proto__', 'constructor', 'prototype']); + +export interface PluginMcpContext { + /** Filesystem-resolved plugin root. */ + realRoot: string; + /** Filesystem-resolved persistent data directory for this plugin instance. */ + dataDirectory: string; + /** Agent Plugins version declared by `plugin.json`. */ + declaredVersion: string; +} + +export interface PluginMcpResult { + servers: PluginMcpServer[]; + diagnostics: PluginDiagnostic[]; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Agent Plugins §9.2: one non-recursive pass replacing every exact occurrence. + * A function replacement keeps `$`-bearing paths literal and prevents text + * introduced by a replacement from being rescanned. + */ +export function expandPluginVariables(value: string, root: string, data: string): string { + return value.replace(PLACEHOLDER_PATTERN, (_match, name: string) => + name === PLUGIN_ROOT_VAR ? root : data, + ); +} + +export function schemaVersion(schemaId: string): string | undefined { + return SCHEMA_VERSION_PATTERN.exec(schemaId)?.[1]; +} + +function serverLocation(name: string): string { + return `${PLUGIN_MCP_FILE}#/mcpServers/${name}`; +} + +function invalidServer(name: string, message: string): PluginDiagnostic { + return { + code: 'mcp_server_invalid', + severity: 'warning', + message, + location: serverLocation(name), + }; +} + +function hasForeignFields(server: Record, allowed: Set): string | null { + for (const key of Object.keys(server)) { + if (!allowed.has(key)) { + return key; + } + } + return null; +} + +function readStringArray(value: unknown): string[] | null { + if (!Array.isArray(value)) { + return null; + } + return value.every((entry) => typeof entry === 'string') ? (value as string[]) : null; +} + +function readStringRecord(value: unknown): Record | null { + if (!isPlainObject(value)) { + return null; + } + const record: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== 'string') { + return null; + } + record[key] = entry; + } + return record; +} + +/** + * §7.2.1: `command` is a single executable token — a bare name resolved by the + * platform, or a plugin-relative path. Shell strings and absolute paths are + * rejected, and no placeholder expansion applies. + */ +async function resolveCommand( + command: string, + realRoot: string, +): Promise<{ command: string } | { error: string }> { + if (isPluginRelativePath(command)) { + const resolved = await realpathAllowingMissing(path.resolve(realRoot, command)); + if (!isWithinRoot(realRoot, resolved)) { + return { error: `"command" resolves outside the plugin root: ${command}` }; + } + return { command: resolved }; + } + if (/[\\/]/.test(command)) { + return { + error: `"command" must be a bare executable name or a "./" plugin-relative path: ${command}`, + }; + } + if (/\s/.test(command)) { + return { error: `"command" must be a single executable token, not a shell string: ${command}` }; + } + return { command }; +} + +/** + * §7.2.1: an explicit `cwd` is plugin-relative, `${PLUGIN_ROOT}`-rooted, or + * `${PLUGIN_DATA}`-rooted, and must stay inside whichever base it names. + */ +async function resolveCwd( + cwd: string, + context: PluginMcpContext, +): Promise<{ cwd: string } | { error: string }> { + const { realRoot, dataDirectory } = context; + const rootedInData = + cwd === `\${${PLUGIN_DATA_VAR}}` || cwd.startsWith(`\${${PLUGIN_DATA_VAR}}/`); + const rootedInRoot = + cwd === `\${${PLUGIN_ROOT_VAR}}` || cwd.startsWith(`\${${PLUGIN_ROOT_VAR}}/`); + + if (!rootedInData && !rootedInRoot && !isPluginRelativePath(cwd)) { + return { + error: `"cwd" must begin with "./", "\${${PLUGIN_ROOT_VAR}}", or "\${${PLUGIN_DATA_VAR}}": ${cwd}`, + }; + } + + const expanded = expandPluginVariables(cwd, realRoot, dataDirectory); + const base = rootedInData ? dataDirectory : realRoot; + const resolved = await realpathAllowingMissing(path.resolve(base, expanded)); + if (!isWithinRoot(base, resolved)) { + return { error: `"cwd" resolves outside ${rootedInData ? 'PLUGIN_DATA' : 'the plugin root'}` }; + } + return { cwd: resolved }; +} + +function validateHeaders(headers: Record): string | null { + const seen = new Set(); + for (const [name, value] of Object.entries(headers)) { + if (!HTTP_TOKEN_PATTERN.test(name)) { + return `"${name}" is not a valid HTTP header name`; + } + const lowered = name.toLowerCase(); + if (seen.has(lowered)) { + return `"${name}" is declared more than once under different casing`; + } + seen.add(lowered); + if (!HTTP_FIELD_VALUE_PATTERN.test(value)) { + return `the value of "${name}" is not a valid HTTP header value`; + } + } + return null; +} + +function isLoopbackHost(hostname: string): boolean { + if (hostname === 'localhost') { + return true; + } + const literal = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname; + return literal === '::1' || LOOPBACK_IPV4_PATTERN.test(literal); +} + +/** §7.2.1: absolute http(s), no userinfo, no fragment, HTTPS off the loopback. */ +function validateUrl(raw: string): string | null { + let url: URL; + try { + url = new URL(raw); + } catch { + return `"url" must be an absolute URL: ${raw}`; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return `"url" must use http or https: ${raw}`; + } + if (url.username !== '' || url.password !== '') { + return '"url" must not contain user information'; + } + if (url.hash !== '') { + return '"url" must not contain a fragment'; + } + if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) { + return '"url" must use https for non-loopback hosts'; + } + return null; +} + +async function readStdioServer( + name: string, + server: Record, + context: PluginMcpContext, +): Promise<{ options: PluginMcpOptions } | { error: PluginDiagnostic }> { + const foreign = hasForeignFields(server, STDIO_FIELDS); + if (foreign !== null) { + return { error: invalidServer(name, `"${foreign}" is not a valid stdio server field`) }; + } + if (typeof server.command !== 'string' || server.command.length === 0) { + return { error: invalidServer(name, 'stdio servers require a "command" string') }; + } + + const rawArgs = server.args === undefined ? [] : readStringArray(server.args); + if (rawArgs === null) { + return { error: invalidServer(name, '"args" must be an array of strings') }; + } + + const rawEnv = server.env === undefined ? {} : readStringRecord(server.env); + if (rawEnv === null) { + return { error: invalidServer(name, '"env" must be an object of strings') }; + } + for (const key of Object.keys(rawEnv)) { + if (key === PLUGIN_ROOT_VAR || key === PLUGIN_DATA_VAR) { + return { + error: invalidServer(name, `"env" must not declare the reserved variable "${key}"`), + }; + } + } + + const resolvedCommand = await resolveCommand(server.command, context.realRoot); + if ('error' in resolvedCommand) { + return { error: invalidServer(name, resolvedCommand.error) }; + } + + let cwd = context.realRoot; + if (server.cwd !== undefined) { + if (typeof server.cwd !== 'string') { + return { error: invalidServer(name, '"cwd" must be a string') }; + } + const resolvedCwd = await resolveCwd(server.cwd, context); + if ('error' in resolvedCwd) { + return { error: invalidServer(name, resolvedCwd.error) }; + } + cwd = resolvedCwd.cwd; + } + + const { realRoot, dataDirectory } = context; + const env: Record = {}; + for (const [key, value] of Object.entries(rawEnv)) { + env[key] = expandPluginVariables(value, realRoot, dataDirectory); + } + env[PLUGIN_ROOT_VAR] = realRoot; + env[PLUGIN_DATA_VAR] = dataDirectory; + + return { + options: { + source: MCP_PLUGIN_SOURCE, + type: 'stdio', + command: resolvedCommand.command, + args: rawArgs.map((arg) => expandPluginVariables(arg, realRoot, dataDirectory)), + env, + cwd, + }, + }; +} + +function readRemoteServer( + name: string, + type: 'streamable-http' | 'sse', + server: Record, +): { options: PluginMcpOptions } | { error: PluginDiagnostic } { + const foreign = hasForeignFields(server, REMOTE_FIELDS); + if (foreign !== null) { + return { error: invalidServer(name, `"${foreign}" is not a valid ${type} server field`) }; + } + if (typeof server.url !== 'string' || server.url.length === 0) { + return { error: invalidServer(name, `${type} servers require a "url" string`) }; + } + const urlError = validateUrl(server.url); + if (urlError !== null) { + return { error: invalidServer(name, urlError) }; + } + + let headers: Record | undefined; + if (server.headers !== undefined) { + const parsed = readStringRecord(server.headers); + if (parsed === null) { + return { error: invalidServer(name, '"headers" must be an object of strings') }; + } + const headerError = validateHeaders(parsed); + if (headerError !== null) { + return { error: invalidServer(name, headerError) }; + } + headers = parsed; + } + + return { + options: { + source: MCP_PLUGIN_SOURCE, + type, + url: server.url, + ...(headers !== undefined && { headers }), + }, + }; +} + +async function readServer( + name: string, + value: unknown, + context: PluginMcpContext, +): Promise<{ options: PluginMcpOptions } | { error: PluginDiagnostic }> { + if (!isPlainObject(value)) { + return { error: invalidServer(name, 'server configuration must be an object') }; + } + const type = value.type; + if (typeof type !== 'string') { + return { error: invalidServer(name, 'server configuration requires a "type" field') }; + } + if (!SUPPORTED_TRANSPORTS.has(type)) { + return { error: invalidServer(name, `"${type}" is not a recognized transport`) }; + } + if (type === 'stdio') { + return readStdioServer(name, value, context); + } + return readRemoteServer(name, type as 'streamable-http' | 'sse', value); +} + +/** + * Validates a parsed `mcp.json` and maps each conforming server onto LibreChat + * MCP options. A malformed document disables MCP for the plugin; a malformed + * entry skips only that server (§7.2.2). + */ +export async function readMcpConfig( + document: unknown, + context: PluginMcpContext, +): Promise { + const diagnostics: PluginDiagnostic[] = []; + const disable = (message: string): PluginMcpResult => ({ + servers: [], + diagnostics: [ + ...diagnostics, + { code: 'mcp_invalid', severity: 'warning', message, location: PLUGIN_MCP_FILE }, + ], + }); + + if (!isPlainObject(document)) { + return disable('mcp.json must contain a top-level JSON object'); + } + + const foreign = hasForeignFields(document, new Set(['$schema', 'mcpServers'])); + if (foreign !== null) { + return disable(`"${foreign}" is not a valid mcp.json field`); + } + + const schemaId = document.$schema; + if (typeof schemaId !== 'string' || schemaId.length === 0) { + return disable('mcp.json is missing the required "$schema" field'); + } + if (schemaId !== PLUGIN_MCP_SCHEMA_ID) { + return disable( + `Unsupported Agent Plugins MCP schema "${schemaId}"; this client implements ${PLUGIN_MCP_SCHEMA_ID}`, + ); + } + const declared = schemaVersion(schemaId); + if (declared !== context.declaredVersion) { + return { + servers: [], + diagnostics: [ + ...diagnostics, + { + code: 'mcp_version_mismatch', + severity: 'warning', + message: `mcp.json targets Agent Plugins ${declared} but plugin.json targets ${context.declaredVersion}`, + location: PLUGIN_MCP_FILE, + }, + ], + }; + } + + if (!isPlainObject(document.mcpServers)) { + return disable('"mcpServers" must be an object'); + } + + const servers: PluginMcpServer[] = []; + for (const [name, value] of Object.entries(document.mcpServers)) { + /** + * Tool keys embed `normalizeServerName(name)` while request-time resolution + * looks the server up by its raw name. A name that changes under + * normalization publishes tools nothing can resolve, so it is rejected here + * rather than failing silently at request time. + */ + if (RESERVED_SERVER_NAMES.has(name)) { + diagnostics.push(invalidServer(name, `"${name}" is a reserved MCP server name`)); + continue; + } + if (normalizeServerName(name) !== name) { + diagnostics.push( + invalidServer( + name, + `"${name}" is not a stable MCP server name; use only the characters preserved by tool naming (it would become "${normalizeServerName(name)}")`, + ), + ); + continue; + } + const result = await readServer(name, value, context); + if ('error' in result) { + diagnostics.push(result.error); + continue; + } + servers.push({ name, options: result.options }); + } + + return { servers, diagnostics }; +} diff --git a/packages/api/src/plugins/paths.ts b/packages/api/src/plugins/paths.ts new file mode 100644 index 0000000000..a3daf80d54 --- /dev/null +++ b/packages/api/src/plugins/paths.ts @@ -0,0 +1,70 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Agent Plugins §4.1: a configuration field defined as a plugin-relative path + * MUST begin with `./`. Bare and parent-relative forms are rejected verbatim + * rather than normalized, so `data` and `../bin` never resolve. + */ +export function isPluginRelativePath(value: string): boolean { + return value.startsWith('./'); +} + +async function realpathOrNull(target: string): Promise { + try { + return await fs.promises.realpath(target); + } catch { + return null; + } +} + +/** + * Resolves `target` through any symlinked ancestors, tolerating a path whose + * leaf does not exist yet. Containment must be judged against the realpath of + * the deepest existing ancestor so a symlinked parent cannot smuggle a + * not-yet-created child outside the plugin root. + */ +export async function realpathAllowingMissing(target: string): Promise { + const absolute = path.resolve(target); + const missingSegments: string[] = []; + let current = absolute; + + for (;;) { + const real = await realpathOrNull(current); + if (real !== null) { + if (missingSegments.length === 0) { + return real; + } + return path.join(real, ...missingSegments.reverse()); + } + const parent = path.dirname(current); + if (parent === current) { + return absolute; + } + missingSegments.push(path.basename(current)); + current = parent; + } +} + +/** True when `target` is the root itself or sits beneath it. */ +export function isWithinRoot(root: string, target: string): boolean { + if (target === root) { + return true; + } + const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`; + return target.startsWith(prefix); +} + +/** + * Resolves a package path against the filesystem-resolved plugin root and + * enforces Agent Plugins §4.1 containment. Returns `null` when the resolved + * path escapes the root; callers map that to the narrowest applicable failure + * boundary for the component they are loading. + */ +export async function resolveWithinRoot( + realRoot: string, + relativePath: string, +): Promise { + const resolved = await realpathAllowingMissing(path.resolve(realRoot, relativePath)); + return isWithinRoot(realRoot, resolved) ? resolved : null; +} diff --git a/packages/api/src/plugins/skills.ts b/packages/api/src/plugins/skills.ts new file mode 100644 index 0000000000..fe4a73a738 --- /dev/null +++ b/packages/api/src/plugins/skills.ts @@ -0,0 +1,132 @@ +import fs from 'fs'; +import path from 'path'; +import type { DeploymentSkill } from '~/skills'; +import type { PluginDiagnostic } from './types'; +import { PLUGIN_SKILLS_DIR, SKILL_MANIFEST_FILE } from './constants'; +import { loadSkillFromDirectory } from '~/skills'; +import { resolveWithinRoot } from './paths'; + +export interface PluginSkillsResult { + skills: DeploymentSkill[]; + diagnostics: PluginDiagnostic[]; +} + +async function isDirectory(target: string): Promise { + try { + return (await fs.promises.stat(target)).isDirectory(); + } catch { + return false; + } +} + +async function isRegularFile(target: string): Promise { + try { + return (await fs.promises.stat(target)).isFile(); + } catch { + return false; + } +} + +/** + * Agent Plugins §7.1: each immediate child of `skills/` holding a regular + * `SKILL.md` is one skill. Deeper descendants are never searched, and a skill + * that fails validation is skipped rather than failing the plugin. + */ +export async function loadPluginSkills( + realRoot: string, + pluginName: string, +): Promise { + const diagnostics: PluginDiagnostic[] = []; + const skillsRoot = await resolveWithinRoot(realRoot, PLUGIN_SKILLS_DIR); + if (skillsRoot === null) { + return { + skills: [], + diagnostics: [ + { + code: 'path_escape', + severity: 'warning', + message: `"${PLUGIN_SKILLS_DIR}/" resolves outside the plugin root; skills were not loaded`, + location: PLUGIN_SKILLS_DIR, + }, + ], + }; + } + + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(skillsRoot, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { skills: [], diagnostics }; + } + return { + skills: [], + diagnostics: [ + { + code: 'component_location_invalid', + severity: 'warning', + message: `"${PLUGIN_SKILLS_DIR}/" could not be read as a directory`, + location: PLUGIN_SKILLS_DIR, + }, + ], + }; + } + + const skills: DeploymentSkill[] = []; + const seenNames = new Set(); + + for (const entry of entries) { + const candidate = path.join(skillsRoot, entry.name); + if (!(await isDirectory(candidate))) { + continue; + } + const relativeDirectory = `${PLUGIN_SKILLS_DIR}/${entry.name}`; + const manifestPath = await resolveWithinRoot( + realRoot, + path.join(PLUGIN_SKILLS_DIR, entry.name, SKILL_MANIFEST_FILE), + ); + if (manifestPath === null) { + diagnostics.push({ + code: 'path_escape', + severity: 'warning', + message: `${SKILL_MANIFEST_FILE} resolves outside the plugin root; the skill was skipped`, + location: relativeDirectory, + }); + continue; + } + if (!(await isRegularFile(manifestPath))) { + continue; + } + + try { + const skill = await loadSkillFromDirectory( + { directory: candidate, relativeDirectory }, + realRoot, + { + idNamespace: `plugin-skill:${pluginName}`, + plugin: pluginName, + }, + ); + if (seenNames.has(skill.name)) { + diagnostics.push({ + code: 'skill_invalid', + severity: 'warning', + message: `Skill "${skill.name}" is declared more than once in this plugin; the later directory was skipped`, + location: relativeDirectory, + }); + continue; + } + seenNames.add(skill.name); + skills.push(skill); + } catch (error) { + diagnostics.push({ + code: 'skill_invalid', + severity: 'warning', + message: error instanceof Error ? error.message : String(error), + location: relativeDirectory, + }); + } + } + + return { skills, diagnostics }; +} diff --git a/packages/api/src/plugins/types.ts b/packages/api/src/plugins/types.ts new file mode 100644 index 0000000000..5facb6a589 --- /dev/null +++ b/packages/api/src/plugins/types.ts @@ -0,0 +1,94 @@ +import type { MCPOptions } from 'librechat-data-provider'; +import type { PluginHookPlan } from '~/agents/hooks'; +import type { MCP_PLUGIN_SOURCE } from '~/utils/env'; +import type { JsonValue } from '~/agents/envelope'; +import type { DeploymentSkill } from '~/skills'; + +export type PluginDiagnosticSeverity = 'error' | 'warning'; + +export type PluginDiagnosticCode = + | 'manifest_missing' + | 'manifest_unreadable' + | 'manifest_invalid_json' + | 'manifest_invalid' + | 'manifest_unknown_field' + | 'manifest_unsupported_version' + | 'manifest_name_conflict' + | 'data_directory_unavailable' + | 'extensions_invalid' + | 'path_escape' + | 'component_location_invalid' + | 'skill_invalid' + | 'mcp_unreadable' + | 'mcp_invalid_json' + | 'mcp_invalid' + | 'mcp_version_mismatch' + | 'mcp_server_invalid' + | 'mcp_transport_unsupported' + | 'hooks_invalid' + | 'hooks_unsupported'; + +export interface PluginDiagnostic { + code: PluginDiagnosticCode; + severity: PluginDiagnosticSeverity; + message: string; + /** Plugin-relative location the diagnostic refers to, when one applies. */ + location?: string; +} + +/** Contents of one client extension namespace; JSON that this client does not validate (§8.1). */ +export type PluginExtensionData = Record; + +export interface PluginAuthor { + name?: string; + email?: string; + url?: string; +} + +export interface PluginManifest { + $schema: string; + name: string; + version?: string; + description?: string; + author?: PluginAuthor; + homepage?: string; + repository?: string; + license?: string; + keywords?: string[]; + extensions?: Record; +} + +/** + * Plugin MCP options always carry their provenance so the connection layer can + * tell them apart from operator-authored config and leave every placeholder the + * plugin declared literal. + */ +export type PluginMcpOptions = MCPOptions & { source: typeof MCP_PLUGIN_SOURCE }; + +export interface PluginMcpServer { + /** Server name as declared in the plugin's `mcpServers` object. */ + name: string; + options: PluginMcpOptions; +} + +/** Hooks contributed through the `ai.librechat` extension directory. */ +export interface PluginHookContribution { + plan: PluginHookPlan; + location: string; +} + +export interface LoadedPlugin { + /** Filesystem-resolved plugin root. */ + root: string; + /** Client-managed persistent data directory supplied to plugin subprocesses. */ + dataDirectory: string; + manifest: PluginManifest; + skills: DeploymentSkill[]; + mcpServers: PluginMcpServer[]; + hooks?: PluginHookContribution; + diagnostics: PluginDiagnostic[]; +} + +export type PluginLoadResult = + | { status: 'loaded'; plugin: LoadedPlugin } + | { status: 'rejected'; root: string; diagnostics: PluginDiagnostic[] }; diff --git a/packages/api/src/skills/deployment.ts b/packages/api/src/skills/deployment.ts index 3824fe055f..195875502f 100644 --- a/packages/api/src/skills/deployment.ts +++ b/packages/api/src/skills/deployment.ts @@ -24,9 +24,16 @@ export const DEPLOYMENT_SKILL_SOURCE = 'deployment'; export const DEPLOYMENT_SKILL_FILE_SOURCE = 'deployment'; const SKILL_MD = 'SKILL.md'; -const DEPLOYMENT_AUTHOR_ID = new Types.ObjectId('de9100000000000000000000'); const MAX_CACHED_TEXT_BYTES = 512 * 1024; +let deploymentAuthorId: Types.ObjectId | undefined; + +/** Constructed on demand so importing this module never depends on a live mongoose binding. */ +function getDeploymentAuthorId(): Types.ObjectId { + deploymentAuthorId ??= new Types.ObjectId('de9100000000000000000000'); + return deploymentAuthorId; +} + type SkillId = Types.ObjectId | string; export type DeploymentSkillFile = { @@ -64,7 +71,7 @@ export type DeploymentSkill = { authorName: string; version: number; source: typeof DEPLOYMENT_SKILL_SOURCE; - sourceMetadata: { deployment: true; directory: string }; + sourceMetadata: { deployment: true; directory: string; plugin?: string }; fileCount: number; alwaysApply: boolean; isPublic: true; @@ -189,6 +196,8 @@ type CollisionFilterResult = { type LoadDeploymentSkillsOptions = { projectRoot?: string; env?: NodeJS.ProcessEnv; + /** Skills contributed by Agent Plugins packages, which yield to the deployment directory on a name conflict. */ + additionalSkills?: DeploymentSkill[]; }; type DirectoryResolution = { @@ -196,11 +205,18 @@ type DirectoryResolution = { explicitlyConfigured: boolean; }; -type LoadedSkillDirectory = { +export type LoadedSkillDirectory = { directory: string; relativeDirectory: string; }; +export type SkillIdentity = { + /** Namespace for the deterministic skill id; keeps same-named skills from different sources distinct. */ + idNamespace?: string; + /** Agent Plugins package that contributed the skill, when it came from one. */ + plugin?: string; +}; + export class DeploymentSkillRegistry { private readonly skillsById = new Map(); private readonly skillsByName = new Map(); @@ -409,6 +425,7 @@ export async function initializeDeploymentSkills( registry = await loadDeploymentSkillsFromDirectory(resolved.directory, { projectRoot: options.projectRoot ?? process.cwd(), explicitlyConfigured: resolved.explicitlyConfigured, + ...(options.additionalSkills !== undefined && { additionalSkills: options.additionalSkills }), }); const count = registry.list().length; if (count > 0) { @@ -421,10 +438,39 @@ export async function initializeDeploymentSkills( return registry; } +/** + * Plugin-contributed skills yield to the deployment directory on a name + * conflict: the operator's own `skill/` tree is the more specific source, and a + * conflict must not fail startup the way a duplicate inside that tree does. + */ +function appendPluginSkills( + skills: DeploymentSkill[], + additionalSkills: DeploymentSkill[], +): DeploymentSkill[] { + const claimed = new Set(skills.map((skill) => skill.name)); + const accepted: DeploymentSkill[] = []; + for (const skill of additionalSkills) { + if (claimed.has(skill.name)) { + logger.warn( + `[deploymentSkills] Plugin skill "${skill.name}" conflicts with a deployment skill and was skipped`, + ); + continue; + } + claimed.add(skill.name); + accepted.push(skill); + } + return accepted; +} + export async function loadDeploymentSkillsFromDirectory( directory: string, - options: { projectRoot?: string; explicitlyConfigured?: boolean } = {}, + options: { + projectRoot?: string; + explicitlyConfigured?: boolean; + additionalSkills?: DeploymentSkill[]; + } = {}, ): Promise { + const additionalSkills = options.additionalSkills ?? []; let rootStat: fs.Stats; try { rootStat = await fs.promises.stat(directory); @@ -433,7 +479,7 @@ export async function loadDeploymentSkillsFromDirectory( (error as NodeJS.ErrnoException).code === 'ENOENT' && options.explicitlyConfigured !== true ) { - return new DeploymentSkillRegistry(directory, []); + return new DeploymentSkillRegistry(directory, additionalSkills); } throw new Error(`Deployment skills directory not found: ${directory}`); } @@ -443,10 +489,11 @@ export async function loadDeploymentSkillsFromDirectory( const skillDirectories = await findSkillDirectories(directory, options.projectRoot ?? directory); const skills = await Promise.all( - skillDirectories.map((skillDirectory) => loadDeploymentSkill(skillDirectory, directory)), + skillDirectories.map((skillDirectory) => loadSkillFromDirectory(skillDirectory, directory)), ); validateUniqueNames(skills); - return new DeploymentSkillRegistry(directory, skills.sort(compareBySkillCursor)); + const merged = [...skills, ...appendPluginSkills(skills, additionalSkills)]; + return new DeploymentSkillRegistry(directory, merged.sort(compareBySkillCursor)); } export function createDeploymentSkillMethods( @@ -615,9 +662,15 @@ async function findSkillDirectories( return directories; } -async function loadDeploymentSkill( +/** + * Loads one `SKILL.md` directory into a deployment skill. Shared by the + * deployment skills directory and Agent Plugins packages, which differ only in + * how the skill is identified and attributed. + */ +export async function loadSkillFromDirectory( skillDirectory: LoadedSkillDirectory, rootDirectory: string, + identity: SkillIdentity = {}, ): Promise { const skillMdPath = path.join(skillDirectory.directory, SKILL_MD); const [content, stat] = await Promise.all([ @@ -665,7 +718,7 @@ async function loadDeploymentSkill( } const derived = deriveStructuredFrontmatterFields(frontmatter); - const skillId = stableObjectId(`deployment-skill:${name}`); + const skillId = stableObjectId(`${identity.idNamespace ?? 'deployment-skill'}:${name}`); const files = await loadDeploymentSkillFiles({ skillId, skillName: name, @@ -679,13 +732,14 @@ async function loadDeploymentSkill( body: content, frontmatter, category: '', - author: DEPLOYMENT_AUTHOR_ID, + author: getDeploymentAuthorId(), authorName: 'Deployment', version: 1, source: DEPLOYMENT_SKILL_SOURCE, sourceMetadata: { deployment: true, directory: skillDirectory.relativeDirectory, + ...(identity.plugin !== undefined && { plugin: identity.plugin }), }, fileCount: files.length, alwaysApply: parsed.alwaysApply ?? false, @@ -736,7 +790,7 @@ async function loadDeploymentSkillFiles({ bytes: stat.size, category: inferSkillFileCategory(relativePath), isExecutable: false, - author: DEPLOYMENT_AUTHOR_ID, + author: getDeploymentAuthorId(), createdAt: stat.birthtime, updatedAt: stat.mtime, ...cache, diff --git a/packages/api/src/skills/sync/github.ts b/packages/api/src/skills/sync/github.ts index 5af41921f3..00cfefa907 100644 --- a/packages/api/src/skills/sync/github.ts +++ b/packages/api/src/skills/sync/github.ts @@ -26,8 +26,15 @@ import { DEFAULT_SKILL_IMPORT_LIMITS } from '../limits'; import { parseSkillMarkdown } from '../parse'; const GITHUB_API_BASE = 'https://api.github.com'; -const SYSTEM_AUTHOR_ID = new Types.ObjectId('000000000000000000000000'); const SYSTEM_AUTHOR_NAME = 'GitHub Sync'; + +let systemAuthorId: Types.ObjectId | undefined; + +/** Constructed on demand so importing this module never depends on a live mongoose binding. */ +function getSystemAuthorId(): Types.ObjectId { + systemAuthorId ??= new Types.ObjectId('000000000000000000000000'); + return systemAuthorId; +} const PROVIDER: SkillSyncProvider = 'github'; const LOCK_LEASE_MS = 30 * 60 * 1000; @@ -800,7 +807,7 @@ async function ensurePublicViewer( resourceType: ResourceType.SKILL, resourceId: skillId, accessRoleId: AccessRoleIds.SKILL_VIEWER, - grantedBy: SYSTEM_AUTHOR_ID, + grantedBy: getSystemAuthorId(), }); } diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index 9534a5a9ab..f18e8538ec 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -9,6 +9,21 @@ import { processOpenIDPlaceholders, } from './oidc'; +/** + * Provenance marker for MCP servers contributed by an Agent Plugins package. + * Applied by the plugin loader, never by a plugin-authored `mcp.json` — that + * schema is closed, so a package declaring this field is rejected outright. + */ +export const MCP_PLUGIN_SOURCE = 'plugin'; + +/** + * True when a server configuration came from an Agent Plugins package, and so + * must reach the transport with every placeholder it declared left literal. + */ +export function isPluginSourced(config?: { source?: string } | null): boolean { + return config?.source === MCP_PLUGIN_SOURCE; +} + /** * List of allowed user fields that can be used in MCP environment variables. * These are non-sensitive string/boolean fields from the IUser interface. @@ -331,7 +346,7 @@ function processAdminValue(originalValue: string, dbSourced: boolean): string { * @returns - The processed object with environment variables replaced */ export function processMCPEnv(params: { - options: Readonly & { dbId?: string }; + options: Readonly & { dbId?: string; source?: string }; user?: Partial; customUserVars?: Record; body?: RequestBody; @@ -344,6 +359,19 @@ export function processMCPEnv(params: { return options; } + /** + * SECURITY INVARIANT — Agent Plugins configurations are returned verbatim. + * Plugin packages are portable third-party data, and the Agent Plugins + * specification (§7.2.1, §9.2) forbids resolving any placeholder a plugin + * declares. Without this gate a plugin could declare a header such as + * `Authorization: Bearer ${OPENAI_API_KEY}` and receive host credentials at + * its own origin. The check reads the config rather than a caller-supplied + * flag so no future call site can reintroduce the leak by omitting it. + */ + if (isPluginSourced(options)) { + return structuredClone(options) as MCPOptions; + } + /** Derive dbSourced from explicit param OR from dbId on the options (failsafe for callers that forget the flag) */ const dbSourced = params.dbSourced ?? !!options.dbId; diff --git a/packages/api/src/utils/pluginEnv.spec.ts b/packages/api/src/utils/pluginEnv.spec.ts new file mode 100644 index 0000000000..3cbd995806 --- /dev/null +++ b/packages/api/src/utils/pluginEnv.spec.ts @@ -0,0 +1,98 @@ +import type { MCPOptions } from 'librechat-data-provider'; +import { MCP_PLUGIN_SOURCE, isPluginSourced, processMCPEnv } from './env'; + +/** + * Agent Plugins packages are third-party data. §7.2.1 and §9.2 forbid resolving + * any placeholder a plugin declares, so a plugin configuration must reach the + * transport byte-for-byte as authored. + */ +describe('processMCPEnv with plugin-sourced configuration', () => { + const user = { id: 'u1', email: 'someone@example.com' } as never; + + beforeEach(() => { + process.env.PLUGIN_LEAK_PROBE = 'super-secret-value'; + }); + + afterEach(() => { + delete process.env.PLUGIN_LEAK_PROBE; + }); + + it('identifies plugin provenance', () => { + expect(isPluginSourced({ source: MCP_PLUGIN_SOURCE })).toBe(true); + expect(isPluginSourced({ source: 'yaml' })).toBe(false); + expect(isPluginSourced({})).toBe(false); + expect(isPluginSourced(undefined)).toBe(false); + }); + + it('leaves an environment placeholder in a header unresolved', () => { + const options = { + source: MCP_PLUGIN_SOURCE, + type: 'streamable-http', + url: 'https://plugin.example.com/mcp', + headers: { Authorization: 'Bearer ${PLUGIN_LEAK_PROBE}' }, + } as unknown as MCPOptions; + + const processed = processMCPEnv({ options, user }) as { headers: Record }; + expect(processed.headers.Authorization).toBe('Bearer ${PLUGIN_LEAK_PROBE}'); + expect(JSON.stringify(processed)).not.toContain('super-secret-value'); + }); + + it('leaves stdio env and args unresolved', () => { + const options = { + source: MCP_PLUGIN_SOURCE, + type: 'stdio', + command: 'node', + args: ['--token', '${PLUGIN_LEAK_PROBE}'], + env: { TOKEN: '${PLUGIN_LEAK_PROBE}' }, + } as unknown as MCPOptions; + + const processed = processMCPEnv({ options, user }) as { + args: string[]; + env: Record; + }; + expect(processed.args[1]).toBe('${PLUGIN_LEAK_PROBE}'); + expect(processed.env.TOKEN).toBe('${PLUGIN_LEAK_PROBE}'); + }); + + it('does not substitute user fields or custom variables', () => { + const options = { + source: MCP_PLUGIN_SOURCE, + type: 'streamable-http', + url: 'https://plugin.example.com/mcp', + headers: { 'X-User': '{{LIBRECHAT_USER_EMAIL}}', 'X-Var': '{{MY_KEY}}' }, + } as unknown as MCPOptions; + + const processed = processMCPEnv({ + options, + user, + customUserVars: { MY_KEY: 'user-supplied' }, + }) as { headers: Record }; + + expect(processed.headers['X-User']).toBe('{{LIBRECHAT_USER_EMAIL}}'); + expect(processed.headers['X-Var']).toBe('{{MY_KEY}}'); + }); + + it('returns a copy rather than the original object', () => { + const options = { + source: MCP_PLUGIN_SOURCE, + type: 'stdio', + command: 'node', + env: { A: 'b' }, + } as unknown as MCPOptions; + + const processed = processMCPEnv({ options }) as { env: Record }; + processed.env.A = 'mutated'; + expect((options as unknown as { env: Record }).env.A).toBe('b'); + }); + + it('still resolves placeholders for operator-authored configuration', () => { + const options = { + type: 'streamable-http', + url: 'https://ops.example.com/mcp', + headers: { Authorization: 'Bearer ${PLUGIN_LEAK_PROBE}' }, + } as unknown as MCPOptions; + + const processed = processMCPEnv({ options, user }) as { headers: Record }; + expect(processed.headers.Authorization).toBe('Bearer super-secret-value'); + }); +}); diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index bf9953d51c..878a381e60 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -288,6 +288,11 @@ export const StdioOptionsSchema = BaseOptionsSchema.extend({ stderr: z .union([z.enum(['pipe', 'ignore', 'inherit']), z.number().int().nonnegative()]) .optional(), + /** + * Working directory for the spawned process. Supplied by Agent Plugins + * packages, which resolve and contain the path before it reaches this schema. + */ + cwd: z.string().optional(), }); export const WebSocketOptionsSchema = BaseOptionsSchema.extend({