Topic: plugin architecture security
MCP server plugin architecture security — runtime plugin loading, plugin isolation, supply chain in the plugin layer
MCP servers that load plugins or extensions at runtime inherit all the supply chain risk of those plugins, plus new risks from capability grant and process isolation failures. A plugin that executes in the main server process has access to every credential the server holds, every module in the server's scope, and every network connection the server can open. Five patterns for securing the plugin layer: sandbox isolation, least-privilege capability grant, plugin integrity verification, plugin lifecycle management, and runtime behavior monitoring.
1. Sandbox isolation for plugin code
The core plugin isolation requirement is that plugin code must not execute in the main server process with access to the full Node.js module scope. A plugin loaded with a standard require() call can access process.env, import any module in the server's dependency tree, and make arbitrary network requests — indistinguishable from the server's own code. The isolation boundary must be enforced at the process level, not at the JavaScript scope level.
Node.js provides two isolation mechanisms suitable for MCP plugin sandboxing: worker_threads for low-overhead in-process isolation with a restricted module context, and child_process.fork() for full process isolation with a communication channel. For plugins that should have zero access to the server's memory, child process isolation is the stronger boundary. For trusted first-party plugins where performance matters, worker threads with a restricted context are a reasonable middle ground.
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads'
import { createHash } from 'crypto'
import { readFileSync } from 'fs'
interface PluginManifest {
name: string
version: string
entrypoint: string
sha256: string
capabilities: string[] // declared capabilities, validated before grant
}
interface SandboxedPlugin {
worker: Worker
manifest: PluginManifest
callTool(name: string, args: unknown): Promise
terminate(): Promise
}
async function loadPluginSandboxed(manifest: PluginManifest): Promise {
// Verify plugin integrity before loading
const pluginCode = readFileSync(manifest.entrypoint)
const actualHash = createHash('sha256').update(pluginCode).digest('hex')
if (actualHash !== manifest.sha256) {
throw new Error(
`Plugin integrity check failed for '${manifest.name}': ` +
`expected ${manifest.sha256}, got ${actualHash}`
)
}
// Capability token: scoped to declared capabilities only
const capabilityToken = buildCapabilityToken(manifest.capabilities)
// Load in worker thread — restricted context, no access to main process env
const worker = new Worker(manifest.entrypoint, {
workerData: { capabilityToken, manifest },
// Prevent worker from accessing main-process environment variables
env: buildRestrictedEnv(manifest.capabilities),
// Resource limits: prevent runaway CPU/memory in plugin code
resourceLimits: {
maxOldGenerationSizeMb: 128,
maxYoungGenerationSizeMb: 32,
codeRangeSizeMb: 32
}
})
return {
worker,
manifest,
async callTool(name: string, args: unknown): Promise {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID()
worker.postMessage({ type: 'tool_call', id, name, args })
const timeout = setTimeout(() => reject(new Error('Plugin tool call timed out')), 10_000)
worker.once('message', (msg) => {
if (msg.id === id) {
clearTimeout(timeout)
if (msg.error) reject(new Error(msg.error))
else resolve(msg.result)
}
})
})
},
async terminate(): Promise {
await worker.terminate()
}
}
}
function buildRestrictedEnv(capabilities: string[]): Record {
// Only expose environment variables that correspond to declared capabilities
const allowed: Record = { NODE_ENV: process.env.NODE_ENV ?? 'production' }
if (capabilities.includes('github_read')) allowed['GITHUB_TOKEN'] = process.env.GITHUB_TOKEN ?? ''
// All other env vars are withheld from the plugin process
return allowed
}
2. Least-privilege capability grant
Even a sandboxed plugin can cause harm if it is granted capabilities beyond what it needs. The least-privilege principle for plugins requires that every capability a plugin can exercise must be explicitly declared in a manifest before the plugin is loaded, validated by the host against what the plugin actually needs for its stated purpose, and delivered as a scoped capability object — not as a reference to the full server API.
A capability object is a plain object containing only the specific functions the plugin is permitted to call. It is not a reference to the server, the credential store, or the tool registry. A plugin that declares ["github_read"] in its manifest receives a capability object that contains { listRepos: fn, readFile: fn } — nothing else. The host constructs this object from the plugin's declared manifest; the plugin cannot expand it at runtime.
// Capability registry: maps capability names to scoped API factories
type CapabilityFactory = (pluginId: string) => Record unknown>
const CAPABILITY_REGISTRY: Record = {
'github_read': (pluginId) => ({
listRepos: (org: string) => githubClient.listRepos(org, { auditPlugin: pluginId }),
readFile: (repo: string, path: string, ref: string) =>
githubClient.readFile(repo, path, ref, { auditPlugin: pluginId })
// No write methods exposed — plugin cannot create webhooks, open PRs, etc.
}),
'filesystem_read': (pluginId) => ({
readFile: (path: string) => sandboxedReadFile(path, pluginId),
listDirectory: (path: string) => sandboxedListDir(path, pluginId)
// No writeFile, no deleteFile
})
}
function buildCapabilityObject(
pluginId: string,
declaredCapabilities: string[]
): Record {
const capabilities: Record = {}
for (const cap of declaredCapabilities) {
if (!(cap in CAPABILITY_REGISTRY)) {
throw new Error(`Plugin '${pluginId}' declares unknown capability: '${cap}'`)
}
// Validate that the capability is appropriate for this plugin's declared purpose
if (!isCapabilityApproved(pluginId, cap)) {
throw new Error(`Capability '${cap}' not approved for plugin '${pluginId}'`)
}
const factory = CAPABILITY_REGISTRY[cap]
// Merge scoped API methods for this capability
Object.assign(capabilities, factory(pluginId))
}
// Return frozen object — plugin cannot add capabilities after load
return Object.freeze(capabilities)
}
// Revocable capability proxy — can be invalidated without restarting the plugin
function makeRevocableCapabilities(capabilities: Record) {
const { proxy, revoke } = Proxy.revocable(capabilities, {
get(target, prop) {
// Log every capability access for audit
auditLog.info('capability_access', { capability: String(prop) })
return Reflect.get(target, prop)
}
})
return { proxy, revoke }
}
3. Plugin integrity verification
Plugin integrity verification ensures that the code loaded at runtime matches the code that was reviewed and approved at install time. Without integrity verification, a supply chain compromise that modifies a plugin's installed files — through a malicious package update, a file system compromise, or a build pipeline attack — is undetectable. The server loads the modified plugin and grants it capabilities as if it were the original.
The verification model mirrors the lockfile pattern already familiar from npm: a plugin lockfile records the expected SHA-256 hash of each plugin's bundle at approved install time. Before any plugin is loaded, the host computes the actual hash of the current plugin files and compares against the lockfile. A mismatch causes a hard failure — the plugin is not loaded, and the operator is alerted.
import { createHash } from 'crypto'
import { readFileSync, writeFileSync } from 'fs'
import { resolve } from 'path'
interface PluginLockEntry {
name: string
version: string
entrypoint: string
sha256: string
approvedAt: string
approvedBy: string
}
type PluginLockfile = Record
const LOCKFILE_PATH = resolve(process.cwd(), 'plugin.lock.json')
function readLockfile(): PluginLockfile {
try {
return JSON.parse(readFileSync(LOCKFILE_PATH, 'utf-8')) as PluginLockfile
} catch {
return {}
}
}
function hashPluginBundle(entrypoint: string): string {
const code = readFileSync(entrypoint)
return createHash('sha256').update(code).digest('hex')
}
function verifyPlugin(manifest: PluginManifest): void {
const lockfile = readLockfile()
const lockEntry = lockfile[manifest.name]
if (!lockEntry) {
throw new Error(
`Plugin '${manifest.name}' has no lockfile entry. ` +
`Run 'mcp-server plugin add ${manifest.name}' to register it.`
)
}
if (lockEntry.version !== manifest.version) {
throw new Error(
`Plugin version mismatch for '${manifest.name}': ` +
`lockfile has ${lockEntry.version}, manifest declares ${manifest.version}`
)
}
const actualHash = hashPluginBundle(manifest.entrypoint)
if (actualHash !== lockEntry.sha256) {
throw new Error(
`Plugin integrity check FAILED for '${manifest.name}'.\n` +
`Expected: ${lockEntry.sha256}\n` +
`Actual: ${actualHash}\n` +
`This may indicate the plugin was modified after approval. ` +
`Do not load this plugin until the modification is explained.`
)
}
}
// CLI command: register a newly installed plugin in the lockfile
function registerPlugin(manifest: PluginManifest, approvedBy: string): void {
const lockfile = readLockfile()
lockfile[manifest.name] = {
name: manifest.name,
version: manifest.version,
entrypoint: manifest.entrypoint,
sha256: hashPluginBundle(manifest.entrypoint),
approvedAt: new Date().toISOString(),
approvedBy
}
writeFileSync(LOCKFILE_PATH, JSON.stringify(lockfile, null, 2))
}
4. Plugin lifecycle management
A robust plugin lifecycle prevents state corruption and ensures that a misbehaving plugin can be isolated without affecting the rest of the server. The lifecycle has four states: load, active, suspend, and unload. Each transition is explicit and audited. Crucially, each plugin gets its own isolated session context — there is no cross-plugin shared mutable state. A plugin crash triggers suspension and logging, not server termination.
type PluginState = 'loading' | 'active' | 'suspended' | 'unloaded'
interface ManagedPlugin {
id: string
manifest: PluginManifest
state: PluginState
sandbox: SandboxedPlugin | null
sessionContext: Map // per-plugin, not shared
lastCallAt: number
errorCount: number
}
class PluginLifecycleManager {
private plugins = new Map()
private readonly IDLE_SUSPEND_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
private readonly MAX_ERROR_COUNT = 5
async load(manifest: PluginManifest): Promise {
verifyPlugin(manifest) // integrity check before load
const sandbox = await loadPluginSandboxed(manifest)
const plugin: ManagedPlugin = {
id: manifest.name,
manifest,
state: 'active',
sandbox,
sessionContext: new Map(), // isolated per-plugin state
lastCallAt: Date.now(),
errorCount: 0
}
this.plugins.set(manifest.name, plugin)
auditLog.info('plugin_loaded', { name: manifest.name, version: manifest.version })
}
async callTool(pluginId: string, toolName: string, args: unknown): Promise {
const plugin = this.plugins.get(pluginId)
if (!plugin) throw new Error(`Plugin '${pluginId}' not found`)
if (plugin.state !== 'active') {
throw new Error(`Plugin '${pluginId}' is ${plugin.state}, not active`)
}
try {
plugin.lastCallAt = Date.now()
const result = await plugin.sandbox!.callTool(toolName, args)
plugin.errorCount = 0 // reset error count on success
return result
} catch (error) {
plugin.errorCount++
auditLog.warn('plugin_tool_error', { pluginId, toolName, errorCount: plugin.errorCount })
if (plugin.errorCount >= this.MAX_ERROR_COUNT) {
await this.suspend(pluginId, 'max_error_count_exceeded')
}
throw error
}
}
async suspend(pluginId: string, reason: string): Promise {
const plugin = this.plugins.get(pluginId)
if (!plugin || plugin.state === 'suspended') return
plugin.state = 'suspended'
plugin.sessionContext.clear() // purge plugin state on suspend
await plugin.sandbox?.terminate()
plugin.sandbox = null
auditLog.warn('plugin_suspended', { pluginId, reason })
}
async unload(pluginId: string): Promise {
await this.suspend(pluginId, 'unload_requested')
this.plugins.delete(pluginId)
auditLog.info('plugin_unloaded', { pluginId })
}
checkIdlePlugins(): void {
const now = Date.now()
for (const [id, plugin] of this.plugins) {
if (plugin.state === 'active' && now - plugin.lastCallAt > this.IDLE_SUSPEND_MS) {
this.suspend(id, 'idle_timeout_30_days')
}
}
}
}
5. Runtime behavior monitoring
Even a sandboxed plugin with verified integrity and least-privilege capabilities may behave unexpectedly at runtime. A plugin that makes outbound HTTP requests to a hostname not in its declared manifest is a strong signal of either a bug or a supply chain compromise. Runtime monitoring intercepts all outbound network requests made by plugin code and validates them against the plugin's declared allowed hosts before the request is sent.
import { request as httpRequest } from 'http'
import { request as httpsRequest } from 'https'
interface PluginNetworkManifest {
allowedHosts: string[] // exact hostnames allowed
allowedHostPatterns: RegExp[] // pattern-based allowlist
maxRequestsPerMinute: number // per-plugin rate limit
}
class PluginNetworkMonitor {
private requestCounts = new Map()
wrapFetch(pluginId: string, netManifest: PluginNetworkManifest) {
// Return a wrapped fetch function scoped to this plugin
return async (url: string | URL, options?: RequestInit): Promise => {
const hostname = new URL(url).hostname
// Allowlist check
const isAllowed =
netManifest.allowedHosts.includes(hostname) ||
netManifest.allowedHostPatterns.some(p => p.test(hostname))
if (!isAllowed) {
auditLog.error('plugin_unexpected_outbound', {
pluginId,
hostname,
url: url.toString(),
severity: 'HIGH'
})
throw new Error(
`Plugin '${pluginId}' attempted request to undeclared host '${hostname}'. ` +
`Allowed hosts: ${netManifest.allowedHosts.join(', ')}`
)
}
// Rate limit check
this.checkRateLimit(pluginId, netManifest.maxRequestsPerMinute)
auditLog.info('plugin_outbound_request', { pluginId, hostname, url: url.toString() })
return fetch(url, options)
}
}
private checkRateLimit(pluginId: string, maxPerMinute: number): void {
const now = Date.now()
const bucket = this.requestCounts.get(pluginId) ?? { count: 0, resetAt: now + 60_000 }
if (now > bucket.resetAt) {
bucket.count = 0
bucket.resetAt = now + 60_000
}
if (bucket.count >= maxPerMinute) {
throw new Error(`Plugin '${pluginId}' exceeded rate limit of ${maxPerMinute} requests/min`)
}
bucket.count++
this.requestCounts.set(pluginId, bucket)
}
// Dead man's switch: alert if a plugin hasn't been called in N days
alertIdlePlugins(plugins: Map, idleThresholdDays: number): void {
const threshold = Date.now() - idleThresholdDays * 24 * 60 * 60 * 1000
for (const [id, plugin] of plugins) {
if (plugin.state === 'active' && plugin.lastCallAt < threshold) {
auditLog.warn('plugin_idle_alert', {
pluginId: id,
lastCallAt: new Date(plugin.lastCallAt).toISOString(),
idleDays: idleThresholdDays
})
}
}
}
}
SkillAudit checks for plugin architecture security
- Plugin code loaded in main process:
require(pluginPath)or dynamicimport(pluginPath)wherepluginPathis not a static string — indicates untrusted plugin code executing with full main-process privileges - No capability scoping: Plugins receiving a reference to the full server instance, the credential store, or the tool registry rather than a scoped capability object
- No plugin integrity verification: Plugin loading code that does not verify a hash or signature before executing plugin code
- No plugin crash isolation: Plugin error handling that re-throws to the main request handler without catching — a plugin crash terminates the server
- No outbound request monitoring: Plugin code with direct
fetch()orhttp.request()calls not intercepted by a network monitor
— SkillAudit scans for these patterns automatically. Scan your MCP server.