@dollhousemcp/mcp-server
Version:
DollhouseMCP - A Model Context Protocol (MCP) server that enables dynamic AI persona management from markdown files, allowing Claude and other compatible AI assistants to activate and switch between different behavioral personas.
1,055 lines • 565 kB
JavaScript
/**
* MCPAQLHandler - Unified handler for all MCP-AQL operations
*
* ARCHITECTURE:
* - Thin resolver pattern: validates → routes → dispatches
* - SOLID principles: depends on abstractions (HandlerRegistry interface)
* - Defense in depth: Gatekeeper validates endpoint/operation matching and enforces policies
* - Dispatch pattern: routes handler references to actual method calls
*
* OPERATION FLOW:
* 1. Validate input structure (type guards)
* 2. Validate permissions (Gatekeeper)
* 3. Route operation (OperationRouter)
* 4. Dispatch to handler (resolveHandlerReference)
* 5. Return standardized result (OperationResult)
*
* ERROR HANDLING:
* All errors are caught and returned as OperationFailure with:
* - success: false
* - error: human-readable message
* - data: never (discriminated union enforces this)
*/
import { Gatekeeper } from './Gatekeeper.js';
import { translateToolConfigToPolicy, canOperationBeElevated } from './policies/index.js';
import { isGatekeeperInfraOperation, findConfirmDenyingElement, findConfirmAdvisoryElements, getGatekeeperDiagnostics } from './policies/ElementPolicies.js';
import { PermissionLevel, GatekeeperErrorCode } from './GatekeeperTypes.js';
import { getRoute } from './OperationRouter.js';
import { ALL_OPERATION_SCHEMAS } from './OperationSchema.js';
import { IntrospectionResolver } from './IntrospectionResolver.js';
import { SchemaDispatcher } from './SchemaDispatcher.js';
import { initializeNormalizers } from './normalizers/index.js';
import { filterFields, isValidPreset, normalizeFieldNames } from '../../utils/FieldFilter.js';
import { parseOperationInput, describeInvalidInput, isBatchRequest, normalizeMCPAQLElementType, } from './types.js';
import { logger } from '../../utils/logger.js';
import { isSearchMatch } from '../../utils/searchUtils.js';
import { PaginationService } from '../../services/query/PaginationService.js';
import { normalizeElementType, ALL_ELEMENT_TYPES, formatElementTypesList } from '../../utils/elementTypeNormalization.js';
import * as yaml from 'js-yaml';
import { SecurityMonitor } from '../../security/securityMonitor.js';
import { SECURITY_LIMITS } from '../../security/constants.js';
import { classifyTool, evaluateCliToolPolicy, assessRisk } from './policies/ToolClassification.js';
import { RateLimiterFactory } from '../../utils/RateLimiter.js';
import { env } from '../../config/env.js';
import { STORAGE_LAYER_CONFIG } from '../../config/performance-constants.js';
import { generateDisplayCode } from '@dollhousemcp/safety';
import { randomUUID } from 'node:crypto';
import { evaluateResiliencePolicy, circuitBreaker } from '../../elements/agents/resilienceEvaluator.js';
import { resilienceMetrics } from '../../elements/agents/resilienceMetrics.js';
import { aggregateElements, validateAggregationOptions } from '../../services/query/AggregationService.js';
import { getPermissionHookStatus } from '../../utils/permissionHooks.js';
import { PERMISSION_AUTHORITY_HOSTS, PERMISSION_AUTHORITY_MODES, readPermissionAuthorityState, } from '../../utils/permissionAuthority.js';
import { ElementType } from '../../portfolio/PortfolioManager.js';
import { prepareHandoffState, parseHandoffBlock, generateHandoffBlock } from '../../elements/agents/handoff.js';
import { getAutonomyMetrics } from '../../elements/agents/autonomyEvaluator.js';
// ============================================================================
// Parameter Validation Utilities (Issue #323)
// ============================================================================
/**
* Validate and extract a required string parameter from params.
* Throws a user-friendly error if the parameter is missing or not a string.
*
* @param params - The parameters object to extract from
* @param paramName - The name of the required parameter
* @param description - Human-readable description for the error message
* @returns The validated string value
* @throws Error with user-friendly message if validation fails
*/
function validateRequiredString(params, paramName, description) {
const value = params[paramName];
if (value === undefined || value === null || typeof value !== 'string' || value.trim() === '') {
throw new Error(`Missing required parameter '${paramName}'. Expected: string (${description})`);
}
return value;
}
const EXECUTION_OPERATION_NAMES = {
execute: 'execute_agent',
getState: 'get_execution_state',
updateState: 'record_execution_step',
complete: 'complete_execution',
continue: 'continue_execution',
abort: 'abort_execution',
getGatheredData: 'get_gathered_data',
prepareHandoff: 'prepare_handoff',
resumeFromHandoff: 'resume_from_handoff',
};
function validateExecutionElementName(method, params) {
const value = params.element_name;
if (value !== undefined && value !== null && typeof value === 'string' && value.trim() !== '') {
return value;
}
const operationName = EXECUTION_OPERATION_NAMES[method] || 'execution lifecycle operation';
if (method === 'getState') {
throw new Error(`Missing required parameter 'element_name'. Expected: string ` +
`(the name of the agent/executable element whose execution state you want to inspect). ` +
`Use the same element_name you passed to execute_agent. ` +
`Retry with: { operation: "get_execution_state", params: { element_name: "code-reviewer", includeDecisionHistory: true } }. ` +
`If you're unsure which name to use, call introspect for "get_execution_state" or list active agents first.`);
}
throw new Error(`Missing required parameter 'element_name'. Expected: string ` +
`(the name of the agent/executable element for ${operationName}). ` +
`If this is part of an existing execution lifecycle, reuse the same element_name you passed to execute_agent. ` +
`If you're unsure which name to use, call introspect for "${operationName}" or list active agents first.`);
}
/**
* Normalize flat pagination params into a { page, pageSize } object.
*
* Issue #500: query_elements and search_elements silently ignored flat
* pageSize/page/limit/offset params. This helper detects these at the
* top level and normalizes them, matching the pattern used by listElements.
*
* Priority: nested pagination > limit/offset > flat page/pageSize > defaults
*/
function normalizePaginationParams(params) {
// If nested pagination is present, use it directly
if (params.pagination && typeof params.pagination === 'object') {
const p = params.pagination;
return {
page: (typeof p.page === 'number' && p.page > 0) ? p.page : 1,
pageSize: (typeof p.pageSize === 'number' && p.pageSize > 0) ? p.pageSize : 20,
};
}
let page = 1;
let pageSize = 20;
// Support limit/offset style (convert to page/pageSize)
if (typeof params.limit === 'number' && params.limit > 0) {
pageSize = params.limit;
if (typeof params.offset === 'number' && params.offset >= 0) {
page = Math.floor(params.offset / params.limit) + 1;
}
}
// Support flat page/pageSize (overrides limit/offset if both present)
if (typeof params.page === 'number' && params.page > 0) {
page = params.page;
}
if (typeof params.pageSize === 'number' && params.pageSize > 0) {
pageSize = params.pageSize;
}
return { page, pageSize };
}
/**
* Valid log categories accepted by LogQueryOptions.
* Matches the LogCategory union type plus the 'all' wildcard.
*/
const VALID_LOG_CATEGORIES = new Set(['application', 'security', 'performance', 'telemetry', 'all']);
/**
* Valid log levels accepted by LogQueryOptions.
* Matches the LogLevel union type.
*/
const VALID_LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error']);
/**
* Validate and sanitize raw params into a safe LogQueryOptions object.
* Strips unknown keys, validates types of known fields, and returns
* only well-typed values. Invalid fields are silently dropped since
* all LogQueryOptions fields are optional.
*
* @param params - Raw params from the MCP request
* @returns Validated LogQueryOptions with only valid fields
*/
function validateLogQueryParams(params) {
const options = {};
if (typeof params.category === 'string' && VALID_LOG_CATEGORIES.has(params.category)) {
options.category = params.category;
}
if (typeof params.level === 'string' && VALID_LOG_LEVELS.has(params.level)) {
options.level = params.level;
}
if (typeof params.source === 'string') {
options.source = params.source;
}
if (typeof params.message === 'string') {
options.message = params.message;
}
if (typeof params.since === 'string') {
options.since = params.since;
}
if (typeof params.until === 'string') {
options.until = params.until;
}
if (typeof params.limit === 'number' && Number.isFinite(params.limit)) {
options.limit = params.limit;
}
if (typeof params.offset === 'number' && Number.isFinite(params.offset)) {
options.offset = params.offset;
}
if (typeof params.correlationId === 'string') {
options.correlationId = params.correlationId;
}
return options;
}
const VALID_METRIC_TYPES = new Set(['counter', 'gauge', 'histogram']);
function validateMetricQueryParams(params) {
const options = {};
if (Array.isArray(params.names)) {
options.names = params.names.filter((n) => typeof n === 'string');
}
if (typeof params.source === 'string') {
options.source = params.source;
}
if (typeof params.type === 'string' && VALID_METRIC_TYPES.has(params.type)) {
options.type = params.type;
}
if (typeof params.since === 'string') {
options.since = params.since;
}
if (typeof params.until === 'string') {
options.until = params.until;
}
if (typeof params.latest === 'boolean') {
options.latest = params.latest;
}
if (typeof params.limit === 'number' && Number.isFinite(params.limit)) {
options.limit = params.limit;
}
if (typeof params.offset === 'number' && Number.isFinite(params.offset)) {
options.offset = params.offset;
}
return options;
}
// ============================================================================
// Verification Security Utilities (Issue #142 — PR #478 review follow-ups)
// ============================================================================
/** UUID v4 format: 8-4-4-4-12 hex digits with version 4 marker and variant bits */
const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* Validate that a challenge ID is a valid UUID v4 format.
* Challenge IDs are generated by crypto.randomUUID() and must conform.
* Rejects obviously invalid IDs before hitting the store to prevent enumeration.
*/
function validateChallengeIdFormat(challengeId) {
if (!UUID_V4_REGEX.test(challengeId)) {
throw new VerificationError(GatekeeperErrorCode.VERIFICATION_FAILED, `Invalid challenge_id format. Expected UUID v4 (e.g., "550e8400-e29b-41d4-a716-446655440000").`);
}
}
/**
* Structured verification error with Gatekeeper error code.
* Enables consistent error handling aligned with existing Gatekeeper patterns.
*/
class VerificationError extends Error {
errorCode;
constructor(errorCode, message) {
super(message);
this.errorCode = errorCode;
this.name = 'VerificationError';
}
}
const DEADLOCK_RELIEF_REASON = 'Deadlock relief requested';
const DEADLOCK_RELIEF_TIMEOUT_MS = 5 * 60 * 1000;
const DEADLOCK_RELIEF_DIALOG_REASON = 'Deadlock relief requested.\n\nThis will deactivate all active elements for the current session and clear persisted activation state.';
/**
* Global rate limiter for verification attempts.
* Tracks failed attempts within a sliding window to prevent brute-force attacks.
*
* Design: Since verification codes are one-time-use (deleted on any attempt),
* the real attack vector is repeated challenge creation + immediate attempt.
* This limiter caps total failures across all challenges within a time window.
*/
class VerificationRateLimiter {
maxFailures;
windowMs;
failures = [];
constructor(maxFailures = 10, windowMs = 60_000) {
this.maxFailures = maxFailures;
this.windowMs = windowMs;
}
/** Record a failed verification attempt. Returns true if rate limit is exceeded. */
recordFailure() {
const now = Date.now();
this.failures.push(now);
this.prune(now);
return this.failures.length > this.maxFailures;
}
/** Check if rate limit is currently exceeded without recording. */
isLimited() {
this.prune(Date.now());
return this.failures.length > this.maxFailures;
}
/** Remove entries outside the sliding window. */
prune(now) {
const cutoff = now - this.windowMs;
while (this.failures.length > 0 && this.failures[0] < cutoff) {
this.failures.shift();
}
}
/** Current failure count in window (for metrics). */
get failuresInWindow() {
this.prune(Date.now());
return this.failures.length;
}
/** Reset (for testing). */
reset() {
this.failures = [];
}
}
class VerificationMetricsTracker {
_totalAttempts = 0;
_totalSuccesses = 0;
_totalFailures = 0;
_totalExpired = 0;
_totalInvalidFormat = 0;
_totalRateLimited = 0;
verifyDurations = [];
static MAX_DURATIONS = 1000;
recordAttempt() { this._totalAttempts++; }
recordSuccess(durationMs) {
this._totalSuccesses++;
if (durationMs !== undefined && durationMs >= 0) {
this.verifyDurations.push(durationMs);
if (this.verifyDurations.length > VerificationMetricsTracker.MAX_DURATIONS) {
this.verifyDurations.shift();
}
}
}
recordFailure() { this._totalFailures++; }
recordExpired() { this._totalExpired++; }
recordInvalidFormat() { this._totalInvalidFormat++; }
recordRateLimited() { this._totalRateLimited++; }
getMetrics(rateLimiter) {
const avgDuration = this.verifyDurations.length > 0
? Math.round(this.verifyDurations.reduce((a, b) => a + b, 0) / this.verifyDurations.length)
: 0;
return {
totalAttempts: this._totalAttempts,
totalSuccesses: this._totalSuccesses,
totalFailures: this._totalFailures,
totalExpired: this._totalExpired,
totalInvalidFormat: this._totalInvalidFormat,
totalRateLimited: this._totalRateLimited,
averageTimeToVerifyMs: avgDuration,
failuresInCurrentWindow: rateLimiter.failuresInWindow,
};
}
/** Reset (for testing). */
reset() {
this._totalAttempts = 0;
this._totalSuccesses = 0;
this._totalFailures = 0;
this._totalExpired = 0;
this._totalInvalidFormat = 0;
this._totalRateLimited = 0;
this.verifyDurations = [];
}
}
export class MCPAQLHandler {
handlers;
contextTracker;
gatekeeper;
/**
* Issue #656: Per-memory save debounce timers.
* When addEntry is called rapidly, we coalesce saves — only the latest
* state is written to disk after the debounce window expires.
* Key: normalized memory name, Value: { timer, memory, manager }
*/
pendingSaves = new Map();
/** Issue #656: Debounce metrics — tracks saves coalesced vs actually written. */
debounceMetrics = { coalesced: 0, written: 0 };
/**
* Issue #657: Per-memory save frequency tracker.
* Sliding window counter: tracks addEntry calls per memory within the monitor window.
* Logs warnings at configurable thresholds to catch runaway loops early.
*/
saveFrequencyCounters = new Map();
/** Issue #625 Phase 4: Rate limiter for permission_prompt evaluations (configurable via env) */
permissionPromptLimiter = RateLimiterFactory.createPermissionPromptLimiter(env.DOLLHOUSE_PERMISSION_PROMPT_RATE_LIMIT, env.DOLLHOUSE_PERMISSION_RATE_WINDOW_MS);
/** Issue #625 Phase 4: Rate limiter for CLI approval record creation (configurable via env) */
cliApprovalLimiter = RateLimiterFactory.createCliApprovalLimiter(env.DOLLHOUSE_CLI_APPROVAL_RATE_LIMIT, env.DOLLHOUSE_PERMISSION_RATE_WINDOW_MS);
/**
* Build a standardized rate-limit deny response for permission_prompt.
*/
buildRateLimitDeny(limiterName, toolName, status, riskLevel = 'blocked', reason = 'Rate limit exceeded') {
SecurityMonitor.logSecurityEvent({
type: 'RATE_LIMIT_EXCEEDED',
severity: 'HIGH',
source: 'MCPAQLHandler.dispatchGatekeeper.permissionPrompt',
details: `${limiterName} rate limit exceeded for ${toolName}`,
additionalData: {
toolName,
limiter: limiterName,
retryAfterMs: status.retryAfterMs,
remainingTokens: status.remainingTokens,
resetTime: status.resetTime.toISOString(),
},
});
return {
behavior: 'deny',
message: `Rate limit exceeded for ${limiterName}. Retry after ${status.retryAfterMs}ms.`,
classification: { riskLevel, reason, stage: 'rate_limit' },
};
}
/** Issue #142: Rate limiter for verify_challenge attempts (max 10 failures per 60s window) */
verificationRateLimiter = new VerificationRateLimiter();
/** Issue #142: Metrics tracker for verification operations */
verificationMetrics = new VerificationMetricsTracker();
/**
* Tracks agents currently in an execution loop for Gatekeeper policy enforcement.
*
* **Lifecycle:** Entries are added in `dispatchExecute()` on `execute_agent` and
* removed on `complete_execution`. Only agents with a gatekeeper policy (explicit
* or synthesized from `tools` config) are tracked.
*
* **Policy resolution:** Entries are included in `getActiveElements()` so the
* Gatekeeper evaluates agent policies alongside persona/skill/ensemble policies.
* The standard priority applies: deny > scope_restriction > confirm > allow.
*
* **Memory safety:** The Map is bounded by concurrently executing agents. If a
* session ends without `complete_execution`, the Map is garbage collected with
* the MCPAQLHandler instance.
*
* Issue #449
*/
executingAgents = new Map();
/**
* Set of aborted goalIds. Once a goalId is aborted, all further execution
* operations (record_execution_step, complete_execution, continue_execution)
* for that goalId are rejected at the dispatch layer.
*
* Issue #249: Abort/cancellation infrastructure.
*/
abortedGoals = new Set();
constructor(handlers, contextTracker) {
this.handlers = handlers;
this.contextTracker = contextTracker;
// Initialize normalizers for schema-driven operations (Issue #243)
initializeNormalizers();
// Issue #452: Store Gatekeeper instance for policy enforcement
if (!handlers.gatekeeper) {
throw new Error('Gatekeeper instance is required in HandlerRegistry. Provide one via the DI container.');
}
this.gatekeeper = handlers.gatekeeper;
}
/**
* Get verification metrics for monitoring/diagnostics.
* Follows the same pattern as DangerZoneEnforcer.getMetrics().
*/
getVerificationMetrics() {
return this.verificationMetrics.getMetrics(this.verificationRateLimiter);
}
/**
* Get autonomy evaluation metrics for monitoring/diagnostics.
* Issue #391: Follows the same pattern as getVerificationMetrics().
*/
getAutonomyMetrics() {
return getAutonomyMetrics();
}
/**
* Gather currently active elements for Gatekeeper policy evaluation.
*
* Queries PersonaManager, SkillManager, and EnsembleManager for active elements,
* then appends any currently executing agents with gatekeeper policies. All
* elements are mapped to the {@link ActiveElement} interface expected by
* `Gatekeeper.enforce()`.
*
* Issue #452: Provides element context for Layer 2 (element policy resolution).
* Issue #449: Includes executing agents alongside personas/skills/ensembles.
*
* @returns Array of active elements with their gatekeeper policies, or empty
* array if gathering fails (fail-open: only route policies apply).
*/
async getActiveElements(sessionId) {
try {
const rawElements = sessionId
? await this.handlers.elementCRUD.getPolicyElementsForReport(sessionId)
: await this.handlers.elementCRUD.getActiveElementsForPolicy();
const activeElements = rawElements.map((el) => ({
type: el.type,
name: el.name,
metadata: {
name: el.name,
description: el.metadata.description ?? undefined,
gatekeeper: el.metadata?.gatekeeper ?? undefined,
...this.copyGatekeeperDiagnostics(el.metadata),
},
}));
// Issue #449: Include executing agents with gatekeeper policies
if (!sessionId) {
for (const [, agentEntry] of this.executingAgents) {
activeElements.push({
type: 'agent',
name: agentEntry.name,
metadata: {
name: agentEntry.name,
gatekeeper: agentEntry.metadata.gatekeeper,
},
});
}
}
return activeElements;
}
catch (error) {
// Fail open — if we can't gather active elements, enforce without them
// This means only route validation and default policies will apply
logger.warn('Failed to gather active elements for Gatekeeper policy evaluation', { error, sessionId });
return [];
}
}
async getPolicyReportElements(sessionId) {
try {
const rawElements = await this.handlers.elementCRUD.getPolicyElementsForReport(sessionId);
return rawElements.map((el) => ({
type: el.type,
name: el.name,
metadata: {
name: el.name,
description: el.metadata.description ?? undefined,
gatekeeper: el.metadata?.gatekeeper ?? undefined,
...this.copyGatekeeperDiagnostics(el.metadata),
...(Array.isArray(el.sessionIds)
? { sessionIds: el.sessionIds }
: {}),
},
}));
}
catch (error) {
logger.warn('Failed to gather policy elements for dashboard reporting', { error, sessionId });
return sessionId ? [] : this.getActiveElements();
}
}
copyGatekeeperDiagnostics(metadata) {
const diagnostics = getGatekeeperDiagnostics(metadata);
return diagnostics ? { gatekeeperDiagnostics: diagnostics } : {};
}
/**
* Handle CREATE operations (additive, non-destructive)
*
* CREATE endpoint operations:
* - create_element: Create new elements
* - import_element: Import elements from exported data
* - addEntry: Add entries to memory elements
*
* Supports batch operations when input contains `operations` array.
*
* @param input - Operation input with operation name and params, or BatchRequest
* @returns OperationResult with success/failure status, or BatchResult for batch operations
*/
async handleCreate(input) {
if (isBatchRequest(input)) {
return this.executeBatch(input, 'CREATE');
}
return this.executeOperation(input, 'CREATE');
}
/**
* Handle READ operations (read-only, safe)
*
* READ endpoint operations:
* - list_elements: List elements with filtering
* - get_element: Get element by name
* - get_element_details: Get detailed element information
* - search_elements: Full-text search
* - query_elements: Query with pagination
* - get_active_elements: Get active elements
* - validate_element: Validate element
* - render: Render template
* - export_element: Export element
* - activate_element: Activate elements for use
* - deactivate_element: Deactivate element
*
* Supports batch operations when input contains `operations` array.
*
* @param input - Operation input with operation name and params, or BatchRequest
* @returns OperationResult with success/failure status, or BatchResult for batch operations
*/
async handleRead(input) {
if (isBatchRequest(input)) {
return this.executeBatch(input, 'READ');
}
return this.executeOperation(input, 'READ');
}
/**
* Handle UPDATE operations (modifying existing state)
*
* UPDATE endpoint operations:
* - edit_element: Modify existing elements
*
* Supports batch operations when input contains `operations` array.
*
* @param input - Operation input with operation name and params, or BatchRequest
* @returns OperationResult with success/failure status, or BatchResult for batch operations
*/
async handleUpdate(input) {
if (isBatchRequest(input)) {
return this.executeBatch(input, 'UPDATE');
}
return this.executeOperation(input, 'UPDATE');
}
/**
* Handle DELETE operations (destructive actions)
*
* DELETE endpoint operations:
* - delete_element: Delete elements
* - clear: Clear memory entries
* - clear_github_auth: Remove GitHub authentication
*
* Supports batch operations when input contains `operations` array.
*
* @param input - Operation input with operation name and params, or BatchRequest
* @returns OperationResult with success/failure status, or BatchResult for batch operations
*/
async handleDelete(input) {
if (isBatchRequest(input)) {
return this.executeBatch(input, 'DELETE');
}
return this.executeOperation(input, 'DELETE');
}
/**
* Handle EXECUTE operations (runtime execution lifecycle)
*
* EXECUTE endpoint operations:
* - execute_agent: Start execution of an agent or executable element
* - get_execution_state: Query current execution state
* - record_execution_step: Record execution progress or findings
* - complete_execution: Signal execution finished successfully
* - continue_execution: Resume execution from saved state
* - abort_execution: Cancel an ongoing execution
*
* Unlike CRUD operations (which are idempotent), EXECUTE operations manage
* runtime state and are inherently non-idempotent. Calling execute twice
* creates two separate executions.
*
* Supports batch operations when input contains `operations` array.
*
* @param input - Operation input with operation name and params, or BatchRequest
* @returns OperationResult with success/failure status, or BatchResult for batch operations
*/
async handleExecute(input) {
if (isBatchRequest(input)) {
return this.executeBatch(input, 'EXECUTE');
}
return this.executeOperation(input, 'EXECUTE');
}
/**
* Core execution logic shared by all CRUD endpoints.
* Implements the thin resolver pattern: validate → route → dispatch → return.
*
* OPERATION FLOW:
* 1. Validate input structure
* 2. Validate permissions (PermissionGuard)
* 3. Route operation (OperationRouter)
* 4. Dispatch to handler
* 5. Return standardized result
*
* @param input - Raw input to validate and process
* @param endpoint - CRUD endpoint being called
* @returns Standardized OperationResult
*/
async executeOperation(input, endpoint) {
// Issue #301: Capture start time for response timing metadata
const startTime = performance.now();
// Step 1: Parse and normalize input (handles both proper and legacy formats)
// Issue #205: Silent JSON fallback for edge cases
const parsedInput = parseOperationInput(input);
// Extract operation name for logging
const operationName = parsedInput?.operation ?? 'unknown';
try {
// Check if parsing succeeded
if (!parsedInput) {
// Provide specific diagnostics depending on what went wrong
const diagnostic = describeInvalidInput(input);
const hasOperation = input && typeof input === 'object' && typeof input.operation === 'string';
const hint = hasOperation
? 'The input structure looks correct but validation failed. If content contains markdown or special characters, ensure the JSON is properly escaped.'
: 'Use format: { operation: "list_elements", params: { type: "personas" } }';
return this.failure(`Invalid input: expected OperationInput with "operation" and optional "params". ${diagnostic}. ${hint}`, startTime);
}
const { operation, elementType, params } = parsedInput;
// Step 2: Enforce Gatekeeper policy (Issue #452)
if (env.DOLLHOUSE_GATEKEEPER_ENABLED) {
// Full 4-layer check: route validation → element policies → session confirmations → defaults
// Issue #758: Gatekeeper infrastructure operations (confirm_operation, verify_challenge, etc.)
// skip element policy evaluation in the primary enforcement path to prevent cascading
// confirmation loops. Element policies for the TARGET operation are evaluated separately
// inside the confirm handler, and deny: ['confirm_operation'] is enforced as a sandbox.
const activeElements = await this.getActiveElements();
const decision = this.gatekeeper.enforce({
operation,
endpoint,
elementType,
activeElements,
skipElementPolicies: isGatekeeperInfraOperation(operation),
});
// Record Gatekeeper decision for metrics
this.handlers.gatekeeperMetricsTracker?.record({
allowed: decision.allowed,
permissionLevel: decision.permissionLevel,
policySource: decision.policySource,
confirmationPending: decision.confirmationPending,
});
if (!decision.allowed) {
if (decision.confirmationPending) {
// Issue #1653: Auto-confirm when the host (Claude Code, etc.) has already
// approved this MCP tool call. The host's tool-level approval is the primary
// human gate; the gatekeeper's confirm_operation round-trip is redundant when
// the host gates every call.
//
// Safety layers that remain active (proven by permission-flow-harness tests):
// - Element deny policies (hard deny, no confirmationPending flag)
// - canBeElevated: false constraints
// - Safety tier evaluation (runs before confirmation check)
// - DangerZone verification (separate flow)
//
// The confirmation is recorded in the session so subsequent enforce() calls
// for the same operation pass without re-confirming.
const confirmLevel = decision.permissionLevel;
// Risk scoring for destructive/high-impact operations.
// Assigns a risk score (0-100) based on operation characteristics.
// This is the MCP-AQL equivalent of assessRisk() for CLI tools.
const riskScore = this.scoreOperationRisk(operation, endpoint, params);
this.gatekeeper.recordConfirmation(operation, confirmLevel, elementType);
// Build and log a detailed summary for session review.
// Even though no human is prompted, this appears in query_logs
// so operators can trace what was auto-confirmed and why.
const summary = this.buildOperationSummary(operation, elementType, params);
const scope = elementType ? ' ['.concat(elementType, ']') : '';
let riskLabel = 'LOW';
if (riskScore >= 80)
riskLabel = 'HIGH';
else if (riskScore >= 40)
riskLabel = 'MODERATE';
const parts = ['[Gatekeeper] Auto-confirmed (', riskLabel, ' risk=', String(riskScore),
'): ', summary, scope, '. Reason: ', decision.reason];
const logMessage = parts.join('');
// CONFIRM_SINGLE_USE operations (delete, execute_agent, edit, abort)
// are higher-risk — log at warn level for visibility in audit trails.
if (confirmLevel === PermissionLevel.CONFIRM_SINGLE_USE) {
logger.warn(logMessage);
}
else {
logger.debug(logMessage);
}
}
else {
// Hard deny — operation is blocked by policy, no confirmation can help
this.recordGatekeeperBlockForAgents(operation, elementType, decision.reason ?? 'Operation blocked by policy', decision.permissionLevel);
throw new Error(`[Gatekeeper] ${decision.reason}`);
}
}
// Issue #673: Protect gatekeeper policy fields from element-policy elevation.
// edit_element is elevatable for normal fields (description, tags, etc.), but
// editing gatekeeper policies always requires explicit user confirmation.
// This prevents an element from auto-approving changes to security policies
// on other elements (the primary privilege escalation vector).
if (operation === 'edit_element' &&
decision.policySource === 'element_policy' &&
decision.permissionLevel === PermissionLevel.AUTO_APPROVE &&
params) {
const inputObj = params.input;
const hasGatekeeperField = inputObj?.gatekeeper !== undefined ||
inputObj?.metadata?.gatekeeper !== undefined;
if (hasGatekeeperField) {
logger.warn(`[MCPAQLHandler] Gatekeeper policy edit blocked from element-policy elevation — requires explicit confirmation`, {
operation,
elementType,
policySource: decision.policySource,
});
return this.failure(`Editing gatekeeper policies requires explicit user confirmation and cannot be auto-approved by element policies. ` +
`Use confirm_operation with params { operation: "edit_element"${elementType ? `, element_type: "${elementType}"` : ''} } to approve, then retry.`, startTime);
}
}
}
else {
// Gatekeeper disabled — fall back to route validation only
Gatekeeper.validate(operation, endpoint);
}
// Step 3: Route operation to handler reference
const route = getRoute(operation);
if (!route) {
// This should never happen after PermissionGuard.validate, but guard defensively
return this.failure(`Unknown operation: ${operation}`, startTime);
}
// Step 4: Dispatch to handler (merge implicitParams from route, user params take precedence)
const mergedParams = route.implicitParams
? { ...route.implicitParams, ...params }
: params ?? {};
const rawData = await this.dispatch(route.handler, {
operation,
elementType,
params: mergedParams,
});
// Step 5: Apply field selection (Issue #202)
// Transform name → element_name for LLM consistency
// Apply field filtering if fields param provided
const data = this.applyFieldSelection(rawData, params);
// Step 6: Log successful operation — only mutations are security-relevant
if (endpoint !== 'READ') {
SecurityMonitor.logSecurityEvent({
type: 'OPERATION_COMPLETED',
severity: 'LOW',
source: `MCPAQLHandler.${endpoint.toLowerCase()}`,
details: elementType
? `${endpoint} '${operation}' completed on ${elementType}`
: `${endpoint} '${operation}' completed`,
additionalData: {
endpoint,
operation,
elementType,
parameterKeys: params ? Object.keys(params) : [],
}
});
}
const durationMs = performance.now() - startTime;
this.handlers.operationMetricsTracker?.record(operationName, endpoint, durationMs, true);
const typeSuffix = elementType ? ':' + elementType : '';
logger.debug(`[MCP-AQL] ${endpoint} ${operation}${typeSuffix} (${durationMs.toFixed(1)}ms)`);
return this.success(data, startTime);
}
catch (error) {
// Catch all errors and return as OperationFailure
const message = error instanceof Error ? error.message : String(error);
const isSecurityViolation = message.includes('Security violation');
const durationMs = performance.now() - startTime;
this.handlers.operationMetricsTracker?.record(operationName, endpoint, durationMs, false);
// Log security events with appropriate severity
SecurityMonitor.logSecurityEvent({
type: isSecurityViolation ? 'UPDATE_SECURITY_VIOLATION' : 'OPERATION_FAILED',
severity: isSecurityViolation ? 'HIGH' : 'MEDIUM',
source: `MCPAQLHandler.${endpoint.toLowerCase()}`,
details: `${endpoint} '${operationName}' failed: ${message}`,
additionalData: { endpoint, operation: operationName, error: message }
});
logger.error(`${endpoint} '${operationName}' failed: ${message}`, {
endpoint,
operation: operationName,
error: message,
stack: error instanceof Error ? error.stack : undefined,
});
return this.failure(message, startTime);
}
}
/**
* Execute a batch of operations sequentially.
* Operations are executed in order, and failures do not stop execution.
*
* EXECUTION SEMANTICS:
* - Operations run sequentially (in order)
* - Each operation is validated independently
* - Failed operations don't stop the batch
* - All results are collected and returned
*
* @param batch - BatchRequest with array of operations
* @param endpoint - CRUD endpoint being called
* @returns BatchResult with all operation results and summary
*/
async executeBatch(batch, endpoint) {
// Issue #221/#543: Reject oversized batches to prevent resource exhaustion
if (batch.operations.length > SECURITY_LIMITS.MAX_BATCH_OPERATIONS) {
SecurityMonitor.logSecurityEvent({
type: 'BATCH_REJECTED',
severity: 'MEDIUM',
source: `MCPAQLHandler.${endpoint.toLowerCase()}.batch`,
details: `Batch of ${batch.operations.length} ops rejected — exceeds limit of ${SECURITY_LIMITS.MAX_BATCH_OPERATIONS}`,
additionalData: { endpoint, requested: batch.operations.length, limit: SECURITY_LIMITS.MAX_BATCH_OPERATIONS },
});
return {
success: false,
results: [],
summary: { total: batch.operations.length, succeeded: 0, failed: batch.operations.length },
error: `Batch size ${batch.operations.length} exceeds maximum of ${SECURITY_LIMITS.MAX_BATCH_OPERATIONS} operations`,
_meta: this.buildMeta(performance.now()),
};
}
// Issue #301: Capture start time for batch-level timing metadata
const startTime = performance.now();
const results = [];
let succeeded = 0;
let failed = 0;
for (let i = 0; i < batch.operations.length; i++) {
const op = batch.operations[i];
// Pass raw operation through — parseOperationInput() in executeOperation()
// handles all normalization (element_type vs elementType, legacy formats, etc.)
const result = await this.executeOperation(op, endpoint);
results.push({
index: i,
operation: op.operation,
result,
});
if (result.success) {
succeeded++;
}
else {
failed++;
}
}
// Log batch completion
SecurityMonitor.logSecurityEvent({
type: 'BATCH_COMPLETED',
severity: 'LOW',
source: `MCPAQLHandler.${endpoint.toLowerCase()}.batch`,
details: `Batch of ${batch.operations.length} ops: ${succeeded} succeeded, ${failed} failed`,
additionalData: {
endpoint,
total: batch.operations.length,
succeeded,
failed,
operations: batch.operations.map(op => op.operation),
failureRate: batch.operations.length > 0 ? Math.round((failed / batch.operations.length) * 100) : 0,
},
});
return {
success: true,
results,
summary: {
total: batch.operations.length,
succeeded,
failed,
},
_meta: this.buildMeta(startTime),
};
}
/**
* Dispatch a handler reference to the actual handler method.
*
* Handler reference format: "Module.method"
* Examples:
* - "ElementCRUD.create" → this.handlers.elementCRUD.createElement(...)
* - "Memory.addEntry" → this.handlers.memoryManager.addEntry(...)
* - "Agent.execute" → this.handlers.agentManager.executeAgent(...)
*
* DISPATCH STRATEGY (Issue #247):
* 1. Check if operation is schema-driven → use SchemaDispatcher
* 2. Otherwise fall through to legacy module-based dispatch
*
* Schema-driven operations benefit from:
* - Declarative configuration (no switch statements)
* - Auto-generated parameter validation
* - Single source of truth for operation metadata
*
* @param handlerRef - Handler reference in "Module.method" format
* @param input - Validated operation input
* @returns Promise resolving to operation-specific data
* @throws Error if handler reference is unknown or method fails
*/
async dispatch(handlerRef, input) {
const { operation, params } = input;
// Issue #247: Schema-driven dispatch for configured operations
// This eliminates the need for manual switch statements
// Issue #251: Pass full input for operations needing elementType resolution
if (SchemaDispatcher.canDispatch(operation)) {
return SchemaDispatcher.dispatch(operation, params || {}, this.handlers, input);
}
// Legacy module-based dispatch for complex operations
const [module, method] = handlerRef.split('.');
// ElementCRUD operations
if (module === 'ElementCRUD') {
return this.dispatchElementCRUD(method, input);
}
// Memory operations
if (module === 'Memory') {
return this.dispatchMemory(method, params);
}
// Agent operations
if (module === 'Agent') {
return this.dispatchAgent(method, params);
}
// Template operations
if (module === 'Template') {
return this.dispatchTemplate(method, params);
}
// Activation operations (cross-cutting concern)
if (module === 'Activation') {
return this.dispatchActivation(method, input);
}
// Search operations
if (module === 'Search') {
return this.dispatchSearch(method, input);
}
// NOTE: UnifiedSearch operations (Issue #243) are now schema-driven
// via SchemaDispatcher with the 'searchParams' normalizer.
// See PORTFOLIO_OPERATIONS.search in OperationSchema.ts
// Introspection operations
if (module === 'Introspection') {
return this.dispatchIntrospection(method, params);
}
// Collection operations (Issue #241)
if (module === 'Collection') {
return this.dispatchCollection(method, params);
}
// Portfolio operations (Issue #241)
if (module === 'Portfolio') {
return this.dispatchPortfolio(method, params);
}
// Auth operations (Issue #241)
if (module === 'Auth') {
return this.dispatchAuth(method, params);
}
// Config operations (Issue #241)
if (module === 'Config') {
return this.dispatchConfig(method, params);
}
// EnhancedIndex operations (Issue #241)
if (module === 'EnhancedIndex') {
return this.dispatchEnhancedIndex(method, params);
}
// Persona operations (Issue #241)
if (module === 'Persona') {
return this.dispatchPersona(method, params);
}
// Execute operations (Issue #244 - CRUDE)
if (module === 'Execute') {
return this.dispatchExecute(method, params);
}
// Gatekeeper operations (Issue #452 - confirmation flow)
if (module === 'Gatekeeper') {
return this.dispatchGatekeeper(method, params);
}
// Logging operations (Issue #528 - CRUDE migration)
if (module === 'Logging') {
return this.dispatchLogging(method, params);
}
// Metrics operations (CRUDE-routed query_metrics)
if (module === 'Metrics') {
return this.dispatchMetrics(method, params);
}
// Browser operations (Issue #774: open portfolio browser)
if (module === 'Browser') {
return this.dispatchBrowser(method, params);
}
throw new Error(`Unknown handler module: ${module}`);
}
/**
* Dispatch ElementCRUD operations to ElementCRUDHandler
*/
async dispatchElementCRUD(method, input) {
const { elementType, params } = input;
const handler = this.handlers.elementCRUD;
const p = params;
switch (method) {
case 'create': {
// Issue #278: For ensembles, merge top-level elements into metadata
// LLMs often pass elements at params level, not inside metadata
const resolvedType = elementType || p.type;
let metadata = p.metadata;
// Check for ensemble type (handles both plural constant and singular form)
const isEnsemble = resolvedType === ElementType.ENSEMBLE || resolvedType === 'ensemble';
if (isEnsemble) {
// Issue #365: Recognize common synonyms for 'elements' (members, components, items)
const synonyms = ['members', 'components', 'items'];
const elementsSource = p.elements || synonyms.reduce((found, syn) => found || p[syn], undefined);
if (elementsSource && (!metadata || !metadata.elements)) {
metadata = { ...metadata, elements: elementsSource };
}
}
// Issue #602: 'instructions' is no longer an API field. Use 'content' for all types.
// If an LLM sends 'instructions', pass it through so createElement can reject with guidance.
return handler.createElement({
name: p.name,
type: resolvedType,
description: p.description,
content: p.content,
instructions: p.instructions, // Rejected with guidance in createElement
metadata,
});
}
case 'list':
return handler.listElements(elementType || p.type, p);
case 'get':
return handler.getElementDetails(p.name, elementType || p.type);
// Issue #738: Currently shares code path with 'get'