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({