// tools/flows/call-tool.flow.ts
import { ConcurrencyLimitError, ExecutionTimeoutError, withTimeout, type SemaphoreTicket } from '@frontmcp/guard';
import { z } from '@frontmcp/lazy-zod';
import { CallToolRequestSchema, CallToolResultSchema, type AuthInfo } from '@frontmcp/protocol';
import {
  buildToolResponseContent,
  isUIRenderFailure,
  MCP_APPS_MIME_TYPE,
  resolveServingMode,
  type ToolResponseContent,
} from '@frontmcp/uipack/adapters';
import {
  checkEntryAvailability,
  findNonFiniteNumber,
  getRuntimeContext,
  isDebug,
  isDevelopment,
  randomUUID,
} from '@frontmcp/utils';

import { getAuthorizedAppIds } from '../../auth/authorized-apps.utils';
import { getConsentedToolIds, isToolConsented } from '../../auth/consent.utils';
import {
  Flow,
  FlowBase,
  FlowControl,
  FlowHooksOf,
  isOrchestratedMode,
  ToolContext,
  ToolEntry,
  type FlowPlan,
  type FlowRunOptions,
} from '../../common';
import { normalizeToolAuthProviders, resolveToolVisibility } from '../../common/metadata/tool.metadata';
import { canDeliverNotifications, handleWaitingFallback, type FallbackHandlerDeps } from '../../elicitation/helpers';
import {
  AuthorizationRequiredError,
  ElicitationFallbackRequired,
  EntryUnavailableError,
  InternalMcpError,
  InvalidInputError,
  InvalidMethodError,
  InvalidOutputError,
  RateLimitError,
  TaskAugmentationNotSupportedError,
  TaskAugmentationRequiredError,
  TaskStoreNotInitializedError,
  ToolCredentialsRequiredError,
  ToolExecutionError,
  ToolNotConsentedError,
  ToolNotFoundError,
} from '../../errors';
import { FlowContextProviders } from '../../provider/flow-context-providers';
import { type Scope } from '../../scope';
import { generateTaskId } from '../../task/helpers/task-id';
import { TaskNotifier } from '../../task/helpers/task-notifier';
import { TASK_DEFAULTS, toWireShape, type TaskRecord } from '../../task/task.types';
import { hasUIConfig } from '../ui';
import { evaluateToolCredentialGate } from './tool-credentials.gate';

/**
 * Type for transport extension on AuthInfo.
 *
 * The MCP SDK's AuthInfo type doesn't include transport natively.
 * The LocalTransportAdapter adds a `transport` property with:
 * - sendElicitRequest: method to send elicitation requests to the client
 * - type: transport type string (e.g., 'sse', 'streamable-http')
 *
 * We use a separate type instead of extending AuthInfo because the MCP SDK's
 * AuthInfo type has strict constraints that conflict with interface extension.
 */
type TransportExtension = {
  transport?: {
    sendElicitRequest: <S extends z.ZodType>(
      relatedRequestId: number | string,
      message: string,
      requestedSchema: S,
      options?: import('../../elicitation').ElicitOptions,
    ) => Promise<import('../../elicitation').ElicitResult<S extends z.ZodType<infer O> ? O : unknown>>;
    type: string;
  };
};

/**
 * Fallback response structure for elicitation.
 * Returned when elicitation is not supported by the client and requires
 * the LLM to collect user input via sendElicitationResult tool.
 */
interface ElicitationFallbackResponse {
  content: Array<{ type: 'text'; text: string }>;
  _meta: {
    elicitationPending: {
      elicitId: string;
      message: string;
      schema: Record<string, unknown>;
      instructions: string;
    };
  };
}

const inputSchema = z.object({
  request: CallToolRequestSchema,
  // z.any() used because ctx is the MCP SDK's ToolCallExtra type which varies by SDK version
  ctx: z.any(),
});

const outputSchema = CallToolResultSchema;

const stateSchema = z.object({
  input: z.looseObject({
    name: z.string().min(1).max(64),
    arguments: z.looseObject({}).optional(),
  }),
  authInfo: z.any().optional() as z.ZodType<AuthInfo>,
  tool: z.instanceof(ToolEntry),
  toolContext: z.instanceof(ToolContext),
  // Store the raw executed output for plugins to see
  rawOutput: z.any().optional(),
  output: outputSchema,
  // Tool owner ID for hook filtering (set during parseInput)
  _toolOwnerId: z.string().optional(),
  // UI result from applyUI stage (if UI config exists)
  uiResult: z.any().optional() as z.ZodType<ToolResponseContent | undefined>,
  // UI metadata from rendering (merged into _meta)
  uiMeta: z.record(z.string(), z.unknown()).optional(),
  // Progress token from request's _meta (for progress notifications)
  progressToken: z.union([z.string(), z.number()]).optional(),
  // JSON-RPC request ID (for elicitation routing)
  jsonRpcRequestId: z.union([z.string(), z.number()]).optional(),
  // Semaphore ticket for concurrency control (set by acquireSemaphore, used by releaseSemaphore)
  semaphoreTicket: z.any().optional(),
  // Task augmentation request (MCP 2025-11-25 tasks spec). Present when the
  // client sent `params.task`. `ttl` MUST be positive — 0 or negative values
  // create a task that expires the instant it's persisted, which looks to the
  // client like a silent drop. Reject them at the validation boundary so the
  // client gets an InvalidInputError instead.
  taskRequest: z
    .object({
      ttl: z.number().int().positive().nullish(),
    })
    .passthrough()
    .optional(),
});

const plan = {
  pre: [
    'parseInput',
    'ensureRemoteCapabilities',
    'findTool',
    // Auth checks run BEFORE task creation so unauthorized callers cannot
    // materialize a task record (which would leak tool existence + hand back a
    // valid taskId). Quota/semaphore are NOT acquired here; the background
    // re-dispatch pays that cost once, not twice.
    'checkToolAuthorization',
    'checkEntryAuthorities',
    'createTaskIfRequested',
    'createToolCallContext',
    // Credential gate runs AFTER the tool context is built (so the
    // CONTEXT-scoped credential accessors — this.authProviders /
    // this.credentials — are resolvable) but BEFORE quota/semaphore and the
    // execute stage, so a missing required credential aborts before execute().
    'checkToolCredentials',
    'acquireQuota',
    'acquireSemaphore',
  ],
  execute: ['validateInput', 'execute', 'validateOutput'],
  finalize: ['releaseSemaphore', 'releaseQuota', 'applyUI', 'finalize'],
} as const satisfies FlowPlan<string>;

declare global {
  interface ExtendFlows {
    'tools:call-tool': FlowRunOptions<
      CallToolFlow,
      typeof plan,
      typeof inputSchema,
      typeof outputSchema,
      typeof stateSchema
    >;
  }
}

const name = 'tools:call-tool' as const;
const { Stage } = FlowHooksOf<'tools:call-tool'>(name);

@Flow({
  name,
  plan,
  inputSchema,
  outputSchema,
  access: 'authorized',
})
export default class CallToolFlow extends FlowBase<typeof name> {
  logger = this.scopeLogger.child('CallToolFlow');

  @Stage('parseInput')
  async parseInput() {
    this.logger.verbose('parseInput:start');

    let method!: string;
    // NOTE: `any` is intentional - Zod parsing validates these values
    // Using unknown would require redundant type guards after schema validation
    let params: any;
    let ctx: any;
    try {
      const inputData = inputSchema.parse(this.rawInput);
      method = inputData.request.method;
      params = inputData.request.params;
      ctx = inputData.ctx;
    } catch (e) {
      throw new InvalidInputError('Invalid Input', e instanceof z.ZodError ? e.issues : undefined);
    }

    if (method !== 'tools/call') {
      this.logger.warn(`parseInput: invalid method "${method}"`);
      throw new InvalidMethodError(method, 'tools/call');
    }

    // Find the tool early to get its owner ID for hook filtering
    const { name } = params;
    const activeTools = this.scope.tools.getTools(true);
    const tool = activeTools.find((entry) => {
      return entry.fullName === name || entry.name === name;
    });

    // Store tool owner ID in state for hook filtering
    const toolOwnerId = tool?.owner?.id;

    // Extract progressToken from request's _meta (for progress notifications)
    const progressToken = params._meta?.progressToken;

    // Extract JSON-RPC request ID for elicitation routing
    const jsonRpcRequestId = ctx.requestId;

    // MCP 2025-11-25 tasks spec: capture `params.task` if present. The tool-level
    // taskSupport check happens in `createTaskIfRequested` after the tool is found.
    const taskRequest =
      params.task && typeof params.task === 'object' ? (params.task as Record<string, unknown>) : undefined;

    this.state.set({
      input: params,
      authInfo: ctx.authInfo,
      _toolOwnerId: toolOwnerId,
      progressToken,
      jsonRpcRequestId,
      taskRequest,
    });
    this.logger.verbose('parseInput:done');
  }

  /**
   * Ensure remote app capabilities are loaded before looking up tools.
   * Remote apps use lazy capability discovery - this triggers the loading.
   * Uses provider registry to find all remote apps across all app registries.
   */
  @Stage('ensureRemoteCapabilities')
  async ensureRemoteCapabilities() {
    this.logger.verbose('ensureRemoteCapabilities:start');

    // Get all apps from all app registries (same approach as ToolRegistry.initialize)
    // This finds remote apps that may be in parent scopes
    const appRegistries = this.scope.providers.getRegistries('AppRegistry');
    const remoteApps: Array<{ id: string; ensureCapabilitiesLoaded?: () => Promise<void> }> = [];

    for (const appRegistry of appRegistries) {
      const apps = appRegistry.getApps();
      for (const app of apps) {
        if (app.isRemote) {
          remoteApps.push(app);
        }
      }
    }

    this.logger.verbose(
      `ensureRemoteCapabilities: found ${remoteApps.length} remote app(s) across ${appRegistries.length} registries`,
    );

    if (remoteApps.length === 0) {
      this.logger.verbose('ensureRemoteCapabilities:skip (no remote apps)');
      return;
    }

    // Trigger capability loading for all remote apps in parallel
    const loadPromises = remoteApps.map(async (app) => {
      // Check if app has ensureCapabilitiesLoaded method (remote apps do)
      if ('ensureCapabilitiesLoaded' in app && typeof app.ensureCapabilitiesLoaded === 'function') {
        try {
          await app.ensureCapabilitiesLoaded();
        } catch (error) {
          this.logger.warn(`Failed to load capabilities for remote app ${app.id}: ${(error as Error).message}`);
        }
      }
    });

    await Promise.all(loadPromises);
    this.logger.verbose('ensureRemoteCapabilities:done');
  }

  @Stage('findTool')
  async findTool() {
    this.logger.verbose('findTool:start');
    // TODO: add support for session based tools
    const activeTools = this.scope.tools.getTools(true);
    this.logger.info(`findTool: discovered ${activeTools.length} active tool(s) (including hidden)`);

    const { name } = this.state.required.input;
    // Hyphen ↔ underscore name fallback (issue #408).
    //
    // Job-management tools renamed from `execute-job` to `execute_job` etc.
    // to align with the MCP/OpenAI snake_case convention. Agents (and any
    // user who memorized the hyphen form before the rename) get a permissive
    // lookup so they don't see TOOL_NOT_FOUND. Only kicks in when the exact
    // name missed, so it never masks a real typo. The alias is shared between
    // the local scope lookup AND the remote-registry fallback below — if it
    // only applied to the first lookup, legacy callers could still miss a
    // tool living in a remote app.
    const alias = /[-_]/.test(name)
      ? name.includes('_')
        ? name.replace(/_/g, '-')
        : name.replace(/-/g, '_')
      : undefined;
    const candidateNames = alias ? [name, alias] : [name];
    const matchesCandidate = (entry: { fullName: string; name: string }) =>
      candidateNames.includes(entry.fullName) || candidateNames.includes(entry.name);

    // Agent invocations (use-agent:*) are routed to agents:call-agent flow
    // by the call-tool-request handler, so they won't reach here
    let tool = activeTools.find(matchesCandidate);

    // Fallback: search directly in remote app registries
    // This handles timing issues where subscription callbacks haven't propagated tools yet
    if (!tool) {
      this.logger.verbose(`findTool: tool "${name}" not in scope registry, checking remote apps directly`);

      const appRegistries = this.scope.providers.getRegistries('AppRegistry');
      for (const appRegistry of appRegistries) {
        const apps = appRegistry.getApps();
        for (const app of apps) {
          if (app.isRemote) {
            const remoteTools = app.tools.getTools(true);
            const remoteTool = remoteTools.find(matchesCandidate);
            if (remoteTool) {
              this.logger.verbose(`findTool: found tool "${name}" in remote app "${app.id}"`);
              tool = remoteTool;
              break;
            }
          }
        }
        if (tool) break;
      }
    }

    // When the resolution came through the alias rather than the original
    // request name, log a one-time deprecation hint so callers can migrate
    // off the legacy spelling. `alias` is undefined when the request name
    // had no `-`/`_`, so this branch is skipped for clean snake_case calls.
    if (tool && alias && tool.fullName !== name && tool.name !== name) {
      this.logger.warn(
        `findTool: tool "${name}" resolved via legacy name alias to "${alias}". ` +
          `Update callers to use "${alias}" — the alias will be removed in a future release.`,
      );
    }

    if (!tool) {
      // Check if tool exists but is unavailable in current environment.
      // This is a REGISTRY-LEVEL constraint (availableWhen), not HTTP/flow-level auth.
      const allUnfiltered = this.scope.tools.listAllInstances();
      const unavailable = allUnfiltered.find((t) => t.fullName === name || t.name === name);
      if (unavailable) {
        const ctx = getRuntimeContext();
        // Issue #417 — read the per-call surface tag so the structured
        // error explains which axis (process-global vs surface) blocked
        // the call. Defaults to 'mcp' for the call-tool handler path.
        const callCtxObj = this.input.ctx as { surface?: 'mcp' | 'cli' | 'http-trigger' | 'job' | 'agent' } | undefined;
        const surface = callCtxObj?.surface ?? 'mcp';
        const check = checkEntryAvailability(unavailable.metadata.availableWhen, ctx, { surface });
        this.logger.warn(
          `findTool: tool "${name}" exists but is unavailable — ` +
            `constraint=${JSON.stringify(unavailable.metadata.availableWhen)}, ` +
            `missingAxes=${JSON.stringify(check.missingAxes)}, ` +
            `current=[os=${ctx.os}, runtime=${ctx.runtime}, deployment=${ctx.deployment}, provider=${ctx.provider}, target=${ctx.target}, surface=${surface}, env=${ctx.env}]`,
        );
        throw new EntryUnavailableError(
          'Tool',
          name,
          unavailable.metadata.availableWhen,
          { ...ctx, surface },
          check.missingAxes,
        );
      }
      this.logger.warn(`findTool: tool "${name}" not found`);
      throw new ToolNotFoundError(name);
    }

    // Internal tools are not callable from external MCP clients. The trusted
    // SDK-side helper (`ExecutionContextBase.callTool`) and other internal
    // dispatchers tag the request ctx with `internalCall: true` to bypass this
    // gate. External tools/call requests get the same response shape as a
    // missing tool — the existence of an internal tool is intentionally not
    // leaked to clients.
    const ctxObj = this.input.ctx as { internalCall?: boolean; taskId?: string } | undefined;
    const visibility = resolveToolVisibility(tool.metadata);
    if (visibility === 'internal' && !ctxObj?.internalCall) {
      this.logger.warn(`findTool: rejecting external tools/call for internal tool "${name}"`);
      throw new ToolNotFoundError(name);
    }

    this.logger = this.logger.child(`CallToolFlow(${name})`);
    this.state.set('tool', tool);
    this.logger.info(`findTool: tool "${name}" found`);
    this.logger.verbose('findTool:done');
  }

  /**
   * MCP 2025-11-25 tasks spec: handle `params.task` on `tools/call`.
   *
   * Enforces the tool-level `execution.taskSupport` contract:
   *  - `'required'` + no `task`  → -32601 (TaskAugmentationRequiredError)
   *  - (absent | `'forbidden'`) + `task` → -32601 (TaskAugmentationNotSupportedError)
   *  - otherwise pass through: task path creates a record and responds with
   *    `CreateTaskResult`, non-task path continues the existing flow.
   */
  @Stage('createTaskIfRequested')
  async createTaskIfRequested() {
    this.logger.verbose('createTaskIfRequested:start');
    const { tool } = this.state.required;
    const taskRequest = this.state.taskRequest;
    const taskSupport = tool.metadata.execution?.taskSupport;
    const toolName = tool.fullName || tool.metadata.name;

    // When the background task-runner re-dispatches the underlying tools/call,
    // it strips `params.task` but sets `ctx.taskId`. Treat that re-dispatch as a
    // trusted internal call: skip all tool-level taskSupport enforcement so a
    // `'required'` tool can actually execute.
    const ctx = this.input.ctx as { taskId?: string } | undefined;
    if (ctx?.taskId) {
      this.logger.verbose('createTaskIfRequested:skip (internal task re-dispatch)');
      return;
    }

    if (taskRequest && (!taskSupport || taskSupport === 'forbidden')) {
      throw new TaskAugmentationNotSupportedError(toolName);
    }
    if (!taskRequest && taskSupport === 'required') {
      throw new TaskAugmentationRequiredError(toolName);
    }
    if (!taskRequest) {
      // Plain call — continue the existing pipeline.
      this.logger.verbose('createTaskIfRequested:skip (non-task call)');
      return;
    }

    const store = this.scope.taskStore;
    const registry = this.scope.tasks;
    if (!store || !registry) {
      throw new TaskStoreNotInitializedError();
    }

    const authInfo = this.state.authInfo;
    const sessionId = authInfo?.sessionId;
    if (!sessionId) {
      // Anonymous callers cannot be safely bound — task records must be session-scoped.
      throw new InternalMcpError('Task-augmented tools/call requires an identified session', 'TASK_SESSION_REQUIRED');
    }

    // Clamp the requested TTL against server policy. Spec §TTL: the server
    // MAY override any requested ttl, including `null` ("unlimited"). We do
    // clamp unlimited to `maxTtlMs` so a misbehaving client can't pin resources
    // forever — this is spec-compliant, and the wire shape reports the actual
    // clamped value so clients aren't lied to about when the task will expire.
    const config = registry.getConfig();
    const rawTtlValue = taskRequest['ttl'];
    // Preserve the explicit-null distinction from omitted/number values so the
    // three cases (number → clamp, null → use server cap, undefined → default)
    // are all handled cleanly.
    const rawTtl: number | null | undefined =
      rawTtlValue === null ? null : typeof rawTtlValue === 'number' ? rawTtlValue : undefined;
    const maxTtl = config.maxTtlMs ?? TASK_DEFAULTS.maxTtlMs;
    const defaultTtl = config.defaultTtlMs ?? TASK_DEFAULTS.defaultTtlMs;
    const ttlMs =
      rawTtl === null
        ? maxTtl // client requested unlimited — server caps at maxTtl
        : rawTtl === undefined
          ? defaultTtl
          : Math.min(rawTtl, maxTtl);
    const pollIntervalMs = config.defaultPollIntervalMs ?? TASK_DEFAULTS.defaultPollIntervalMs;

    const now = Date.now();
    const nowIso = new Date(now).toISOString();
    const taskId = generateTaskId();
    const expiresAt = now + ttlMs;

    // Strip the `task` field before re-dispatching — the background flow must
    // execute the tool normally, not loop back into task creation.
    const cleanedParams: Record<string, unknown> = { ...this.state.required.input };
    delete cleanedParams['task'];

    const record: TaskRecord = {
      taskId,
      sessionId,
      status: 'working',
      statusMessage: 'The operation is now in progress.',
      createdAt: nowIso,
      lastUpdatedAt: nowIso,
      ttlMs,
      pollIntervalMs,
      expiresAt,
      request: { method: 'tools/call', params: cleanedParams },
    };
    if (this.state.progressToken !== undefined) {
      record.progressToken = this.state.progressToken;
    }

    await store.create(record);

    const notifier = new TaskNotifier(this.scope.notifications, this.logger);
    // Emit the initial `working` status notification on the same SSE stream as
    // the CreateTaskResult response so clients that don't maintain a long-lived
    // GET stream still observe the task coming to life. Best-effort per spec.
    notifier.sendStatus(record);

    // Delegate execution to the configured runner (in-process for long-lived
    // Node servers, CLI-spawned child for `__cliMode`). The runner handles
    // persisting `executor.{host,pid,spawnedAt}` as part of scheduling.
    const runner = registry.runner;
    if (!runner) {
      throw new InternalMcpError(
        'Task runner is not configured — scope initialization did not set one',
        'TASK_RUNNER_MISSING',
      );
    }
    try {
      await runner.run(record, { cleanedRequestParams: cleanedParams, ctx: this.input.ctx });
    } catch (err) {
      this.logger.error('createTaskIfRequested: runner.run threw', {
        taskId,
        error: err instanceof Error ? err.message : String(err),
      });
      // Best-effort: mark the task failed so the client doesn't poll forever.
      // Wrap in its own try/catch so a bookkeeping write failure doesn't mask
      // the original runner error that caused us to get here in the first place.
      try {
        await store.update(taskId, sessionId, {
          status: 'failed',
          statusMessage: 'Task runner failed to schedule execution',
        });
      } catch (bookkeepingErr) {
        this.logger.warn('createTaskIfRequested: failed to mark task failed after runner error', {
          taskId,
          sessionId,
          error: bookkeepingErr instanceof Error ? bookkeepingErr.message : String(bookkeepingErr),
        });
      }
      throw err;
    }

    // Respond with a CreateTaskResult — a CallToolResult-shaped object carrying
    // a `task` field and the related-task _meta per spec.
    const createTaskResult = {
      task: toWireShape(record),
      _meta: {
        'io.modelcontextprotocol/related-task': { taskId },
      },
    };

    this.logger.info('createTaskIfRequested: task created, responding with CreateTaskResult', {
      taskId,
      tool: toolName,
    });
    this.respond(createTaskResult as unknown as Parameters<typeof this.respond>[0]);
  }

  /**
   * Check if the tool's parent app is authorized.
   * For progressive authorization, tools from unauthorized apps
   * return an AuthorizationRequiredError with an auth_url.
   */
  @Stage('checkToolAuthorization')
  async checkToolAuthorization() {
    this.logger.verbose('checkToolAuthorization:start');
    const { tool, authInfo } = this.state;

    // ----- Consent enforcement (per-client tool selection) -----
    // When the verified token carries an ENABLED `consent` claim, the caller
    // may only invoke tools they selected on the consent screen. A token with
    // NO consent metadata (consent disabled) returns `undefined` here and the
    // check is skipped entirely — preserving the default allow-all behavior.
    //
    // Trusted internal dispatch (`internalCall: true`, e.g. a tool/agent
    // invoking another tool) bypasses consent: the user consented to the entry
    // tool, and inner composition is an implementation detail — mirroring the
    // internal-visibility bypass in `findTool`.
    const consentCtx = this.input.ctx as { internalCall?: boolean } | undefined;
    if (!consentCtx?.internalCall) {
      const consentedToolIds = getConsentedToolIds(authInfo);
      if (consentedToolIds && tool && !isToolConsented(consentedToolIds, tool.name, tool.fullName)) {
        this.logger.info(
          `checkToolAuthorization: tool "${tool.fullName || tool.name}" not in consented set — rejecting`,
        );
        throw new ToolNotConsentedError(tool.fullName || tool.name);
      }
    }

    // Resolve the authorized-app set this request may use. Two sources, both
    // optional — their ABSENCE means "no app-level gating" (default allow-all):
    //  1. Progressive/Incremental auth: the verified token's `authorized_apps`
    //     claim, surfaced by `getAuthorizedAppIds`. This claim is ONLY minted
    //     when `incrementalAuth` is enabled for the scope, so non-incremental
    //     setups never see it and behave exactly as before.
    //  2. Legacy `authInfo.extra.authorization` projection (e.g. direct mode or
    //     a host that pre-builds a session-shaped authorization). Preserved for
    //     back-compat.
    const claimAuthorizedApps = getAuthorizedAppIds(authInfo);
    const legacyAuthorization = authInfo?.extra?.['authorization'] as
      | {
          authorizedAppIds?: string[];
          authorizedApps?: Record<string, unknown>;
        }
      | undefined;

    // No app set from either source = no app-level gating, skip the check.
    if (!claimAuthorizedApps && !legacyAuthorization) {
      this.logger.verbose('checkToolAuthorization:skip (no auth context)');
      return;
    }

    // Get app ID from tool owner (uses existing lineage system)
    const appId = tool?.owner?.id;
    if (!appId) {
      // Tool has no owner = global tool, skip app-level authorization check
      this.logger.verbose('checkToolAuthorization:skip (no owner)');
      return;
    }

    // Check if app is authorized in EITHER source (a grant in either suffices).
    const isAppAuthorized =
      (claimAuthorizedApps?.has(appId) ?? false) ||
      (legacyAuthorization?.authorizedAppIds?.includes(appId) ?? false) ||
      appId in (legacyAuthorization?.authorizedApps || {});

    if (!isAppAuthorized) {
      // Get incremental auth configuration from scope's auth options
      const apps = this.scope.apps.getApps();
      const app = apps.find((a) => a.metadata.id === appId);
      const authOptions = this.scope.auth?.options;

      // Determine skippedAppBehavior:
      // 1. Only applies in orchestrated mode with incrementalAuth configured
      // 2. Default to 'anonymous' if not configured (allow anonymous fallback)
      let skippedBehavior: 'anonymous' | 'require-auth' = 'anonymous';

      if (authOptions && isOrchestratedMode(authOptions)) {
        // Orchestrated mode - check incrementalAuth config
        const incrementalConfig = authOptions.incrementalAuth;
        if (incrementalConfig) {
          // If incremental auth is disabled, always require auth
          if (incrementalConfig.enabled === false) {
            skippedBehavior = 'require-auth';
          } else {
            skippedBehavior = incrementalConfig.skippedAppBehavior || 'anonymous';
          }
        }
        // If incrementalAuth not configured, default is enabled with 'anonymous' behavior
      }
      // For public/transparent modes, default 'anonymous' behavior applies

      if (skippedBehavior === 'anonymous' && app?.metadata?.auth?.mode === 'public') {
        // App supports anonymous - continue with anonymous access
        this.logger.verbose(`checkToolAuthorization: using anonymous for ${appId}`);
        return;
      }

      // Require explicit authorization - build auth URL
      const authUrl = this.buildProgressiveAuthUrl(appId, tool?.fullName || '');

      this.logger.info(`checkToolAuthorization: authorization required for app "${appId}"`);
      throw new AuthorizationRequiredError({
        appId,
        toolId: tool?.fullName || tool?.name || 'unknown',
        authUrl,
        message: `Authorization required for ${appId}. Please authorize to use ${tool?.fullName || tool?.name}.`,
      });
    }

    this.logger.verbose('checkToolAuthorization:done');
  }

  /**
   * Build the URL for progressive/incremental authorization
   */
  private buildProgressiveAuthUrl(appId: string, toolId: string): string {
    const baseUrl = this.scope.fullPath || '';
    return `${baseUrl}/oauth/authorize?app=${encodeURIComponent(appId)}&tool=${encodeURIComponent(toolId)}`;
  }

  /**
   * Check entry-level authorities (RBAC/ABAC/ReBAC) declared in tool metadata.
   * Hookable: developers can use Will/Did/Around on 'checkEntryAuthorities'.
   * Skips silently if no authorities engine is configured or no authorities on the tool.
   */
  @Stage('checkEntryAuthorities')
  async checkEntryAuthorities() {
    this.logger.verbose('checkEntryAuthorities:start');
    const engine = this.scope.authoritiesEngine;
    const ctxBuilder = this.scope.authoritiesContextBuilder;
    if (!engine || !ctxBuilder) {
      this.logger.verbose('checkEntryAuthorities:skip (no engine configured)');
      return;
    }

    const tool = this.state.tool;
    if (!tool) return;

    const metadata = tool.metadata as unknown as Record<string, unknown>;
    const authorities = metadata['authorities'];
    if (!authorities) {
      this.logger.verbose('checkEntryAuthorities:skip (no authorities on tool)');
      return;
    }

    const authInfo = this.state.authInfo ?? {};
    const stateInput = this.state.input;
    const input = ((stateInput as Record<string, unknown>)?.['arguments'] ?? stateInput ?? {}) as Record<
      string,
      unknown
    >;

    const evalCtx = ctxBuilder.build(authInfo as Record<string, unknown>, input);
    const result = await engine.evaluate(authorities as import('@frontmcp/auth').AuthoritiesMetadata, evalCtx);

    if (!result.granted) {
      // Resolve required scopes from scopeMapping if available
      let requiredScopes: string[] | undefined;
      const scopeMapping = this.scope.authoritiesScopeMapping;
      if (scopeMapping && result.denial) {
        const { resolveRequiredScopes } = await import('@frontmcp/auth');
        requiredScopes = resolveRequiredScopes(
          result.denial,
          scopeMapping,
          authorities as import('@frontmcp/auth').AuthoritiesMetadata,
        );
      }

      const { AuthorityDeniedError } = await import('@frontmcp/auth');
      throw new AuthorityDeniedError({
        entryType: 'Tool',
        entryName: tool.fullName || tool.name,
        deniedBy: result.deniedBy ?? 'policy denied',
        denial: result.denial,
        requiredScopes,
      });
    }

    this.logger.verbose('checkEntryAuthorities:done');
  }

  @Stage('createToolCallContext')
  async createToolCallContext() {
    this.logger.verbose('createToolCallContext:start');
    const { ctx } = this.input;
    const { tool, input } = this.state.required;
    // authInfo is optional - access separately to avoid "required" throwing
    const authInfo = this.state.authInfo;
    const progressToken = this.state.progressToken;

    try {
      // Build context-scoped providers from the tool's provider registry (app-level).
      // This ensures CONTEXT-scoped providers registered at the app level are available.
      const sessionKey = authInfo?.sessionId ?? 'anonymous';
      const toolViews = await tool.providers.buildViews(sessionKey, new Map(this.deps));

      // Merge tool's context providers with flow's context deps
      const mergedContextDeps = new Map(this.deps);
      for (const [token, instance] of toolViews.context) {
        if (!mergedContextDeps.has(token)) {
          mergedContextDeps.set(token, instance);
        }
      }

      // Create context-aware providers that include scoped providers from both
      // the scope (via flow deps) and the tool's app (via toolViews).
      const contextProviders = new FlowContextProviders(tool.providers, mergedContextDeps);
      const context = tool.create(input.arguments, { ...ctx, progressToken, contextProviders });
      const toolHooks = this.scope.hooks.getClsHooks(tool.record.provide).map((hook) => {
        hook.run = async () => {
          return context[hook.metadata.method]();
        };
        return hook;
      });

      this.appendContextHooks(toolHooks);
      context.mark('createToolCallContext');

      // Set tool name and input for fallback elicitation support
      // These are used by the elicit() method when throwing ElicitationFallbackRequired
      context._toolNameInternal = tool.metadata.id ?? tool.metadata.name;
      context._toolInputInternal = input.arguments;

      // Wire transport to FrontMcpContext for elicitation support
      // The transport is stored in authInfo.transport by the local adapter
      // Cast with TransportExtension since MCP SDK's AuthInfo doesn't include transport
      const frontmcpContext = context.tryGetContext();
      const authInfoWithTransport = authInfo as (AuthInfo & TransportExtension) | undefined;
      if (frontmcpContext && authInfoWithTransport?.transport?.sendElicitRequest) {
        const transport = authInfoWithTransport.transport;
        // Pass the JSON-RPC request ID for proper elicitation routing
        // The MCP SDK uses this to route messages through the correct SSE stream
        const jsonRpcRequestId = this.state.jsonRpcRequestId;
        frontmcpContext.setTransport({
          sendElicitRequest: transport.sendElicitRequest.bind(transport),
          type: transport.type,
          jsonRpcRequestId,
        });
      }

      this.state.set('toolContext', context);
      this.logger.verbose('createToolCallContext:done');
    } catch (error) {
      this.logger.error('createToolCallContext: failed to create context', error);
      throw new ToolExecutionError(tool.metadata.name, error instanceof Error ? error : undefined);
    }
  }

  /**
   * TOOL-LEVEL credential gate.
   *
   * Reads the tool's `authProviders` and, for every provider declared
   * `required: true` (the default per the metadata), verifies a credential is
   * available for the current session BEFORE `execute()` runs. A missing
   * required credential aborts the call with {@link ToolCredentialsRequiredError}
   * (JSON-RPC -32001 / MCP UNAUTHORIZED) carrying the provider id(s) and a
   * connect/authorize `authUrl`.
   *
   * Runs after `createToolCallContext` so the CONTEXT-scoped credential
   * accessors (`this.authProviders` / `this.credentials`) are resolvable via the
   * built tool context, and before `acquireQuota`/`acquireSemaphore`/`execute`.
   *
   * Behavior is intentionally a no-op — preserving the pre-existing call-tool
   * path exactly — when:
   *  - the tool declares no `authProviders`, or
   *  - all declared providers are `required: false`, or
   *  - NO credential accessor is configured in the running scope (public mode,
   *    no auth, vault disabled), or
   *  - the call is trusted internal dispatch / a background task re-dispatch.
   *
   * Hookable: developers may use Will/Did/Around on 'checkToolCredentials'.
   */
  @Stage('checkToolCredentials')
  async checkToolCredentials() {
    this.logger.verbose('checkToolCredentials:start');
    const { tool } = this.state.required;

    // Trusted internal dispatch (tool/agent invoking another tool, or the task
    // re-dispatch) is exempt — the entry tool already passed its own gate and
    // inner composition is an implementation detail. Mirrors the consent/
    // internal-visibility bypass in findTool/checkToolAuthorization.
    const ctxObj = this.input.ctx as { internalCall?: boolean; taskId?: string } | undefined;
    if (ctxObj?.internalCall || ctxObj?.taskId) {
      this.logger.verbose('checkToolCredentials:skip (internal call)');
      return;
    }

    const providers = normalizeToolAuthProviders(
      (tool.metadata as unknown as Record<string, unknown>)['authProviders'],
    );
    if (providers.length === 0) {
      this.logger.verbose('checkToolCredentials:skip (no authProviders)');
      return;
    }

    // Resolve credential availability through the built tool context so the
    // CONTEXT-scoped accessors are in scope. `tryGet` returns undefined (never
    // throws) when an accessor isn't wired, letting the gate skip cleanly.
    const toolContext = this.state.toolContext;
    if (!toolContext) {
      // Defensive: context build was skipped/failed — don't introduce gating.
      this.logger.verbose('checkToolCredentials:skip (no tool context)');
      return;
    }
    toolContext.mark('checkToolCredentials');

    const result = await evaluateToolCredentialGate(providers, (token) => toolContext.tryGet(token));

    if (!result.evaluated) {
      // No credential accessor wired — cannot gate. Preserve legacy behavior.
      this.logger.verbose('checkToolCredentials:skip (no credential accessor configured)');
      return;
    }

    if (result.missing.length === 0) {
      this.logger.verbose('checkToolCredentials:done (all required credentials present)');
      return;
    }

    const toolId = tool.fullName || tool.metadata.name;
    const missingNames = result.missing.map((m) => m.name);
    // Surface the first resolvable connect/authorize URL (callers re-auth one
    // provider at a time; the data also lists every missing provider id).
    const authUrl = result.missing.find((m) => m.authUrl)?.authUrl;

    this.logger.info(
      `checkToolCredentials: tool "${toolId}" missing required credential(s): ${missingNames.join(', ')}`,
    );
    throw new ToolCredentialsRequiredError({ toolId, providers: missingNames, authUrl });
  }

  @Stage('acquireQuota')
  async acquireQuota() {
    this.logger.verbose('acquireQuota:start');

    const manager = this.scope.rateLimitManager;
    if (!manager) {
      this.state.toolContext?.mark('acquireQuota');
      this.logger.verbose('acquireQuota:done (no rate limit manager)');
      return;
    }

    const { tool } = this.state.required;
    const context = this.tryGetContext();
    const partitionCtx = context
      ? {
          sessionId: context.sessionId,
          clientIp: context.metadata?.clientIp,
          userId: context.authInfo?.clientId as string | undefined,
        }
      : undefined;

    // Check global rate limit first
    const globalResult = await manager.checkGlobalRateLimit(partitionCtx);
    if (!globalResult.allowed) {
      this.logger.warn('acquireQuota: global rate limit exceeded', {
        retryAfterMs: globalResult.retryAfterMs,
      });
      throw new RateLimitError(Math.ceil((globalResult.retryAfterMs ?? 60_000) / 1000));
    }

    // Check per-tool rate limit
    const result = await manager.checkRateLimit(tool.metadata.name, tool.metadata.rateLimit, partitionCtx);
    if (!result.allowed) {
      this.logger.warn('acquireQuota: tool rate limit exceeded', {
        tool: tool.metadata.name,
        retryAfterMs: result.retryAfterMs,
      });
      throw new RateLimitError(Math.ceil((result.retryAfterMs ?? 60_000) / 1000));
    }

    this.state.toolContext?.mark('acquireQuota');
    this.logger.verbose('acquireQuota:done');
  }

  @Stage('acquireSemaphore')
  async acquireSemaphore() {
    this.logger.verbose('acquireSemaphore:start');

    const manager = this.scope.rateLimitManager;
    if (!manager) {
      this.state.toolContext?.mark('acquireSemaphore');
      this.logger.verbose('acquireSemaphore:done (no rate limit manager)');
      return;
    }

    const { tool } = this.state.required;
    const config = tool.metadata.concurrency;
    if (!config) {
      this.state.toolContext?.mark('acquireSemaphore');
      this.logger.verbose('acquireSemaphore:done (no concurrency config)');
      return;
    }

    const context = this.tryGetContext();
    const partitionCtx = context
      ? {
          sessionId: context.sessionId,
          clientIp: context.metadata?.clientIp,
          userId: context.authInfo?.clientId as string | undefined,
        }
      : undefined;

    const ticket = await manager.acquireSemaphore(tool.metadata.name, config, partitionCtx);
    if (!ticket) {
      this.logger.warn('acquireSemaphore: concurrency limit reached', {
        tool: tool.metadata.name,
        maxConcurrent: config.maxConcurrent,
      });
      throw new ConcurrencyLimitError(tool.metadata.name, config.maxConcurrent);
    }

    this.state.set('semaphoreTicket', ticket);
    this.state.toolContext?.mark('acquireSemaphore');
    this.logger.verbose('acquireSemaphore:done');
  }

  @Stage('validateInput')
  async validateInput() {
    this.logger.verbose('validateInput:start');
    const { tool, input } = this.state.required;
    const { toolContext } = this.state;
    if (!toolContext) {
      return;
    }
    toolContext.mark('validateInput');

    try {
      toolContext.input = tool.parseInput(input);
      this.logger.verbose('validateInput:done');
    } catch (err) {
      if (err instanceof z.ZodError) {
        throw new InvalidInputError('Invalid tool input', err.issues);
      }

      this.logger.error('validateInput: failed to parse input', err);
      throw new InvalidInputError('Unknown error occurred when trying to parse input');
    }
  }

  @Stage('execute')
  async execute() {
    this.logger.verbose('execute:start');
    const toolContext = this.state.toolContext;
    if (!toolContext) {
      return;
    }
    toolContext.mark('execute');

    const { tool } = this.state.required;
    const timeoutMs =
      tool.metadata.timeout?.executeMs ?? this.scope.rateLimitManager?.config?.defaultTimeout?.executeMs;

    try {
      const doExecute = async () => {
        toolContext.output = await toolContext.execute(toolContext.input);
      };

      if (timeoutMs) {
        await withTimeout(doExecute, timeoutMs, tool.metadata.name);
      } else {
        await doExecute();
      }

      this.logger.verbose('execute:done');
    } catch (error) {
      // FlowControl is a control-flow signal (this.fail / this.respond / this.handled
      // from inside the tool). Re-throw without wrapping so callers (HTTP /
      // Direct / CLI) can extract the originating error from `originalError`
      // and surface its public message instead of a generic "Unknown error".
      if (error instanceof FlowControl) {
        throw error;
      }
      // Re-throw timeout errors without wrapping
      if (error instanceof ExecutionTimeoutError) {
        this.logger.warn('execute: tool execution timed out', {
          tool: tool.metadata.name,
          timeoutMs,
        });
        throw error;
      }
      // Handle elicitation fallback for clients that don't support elicitation
      if (error instanceof ElicitationFallbackRequired) {
        this.logger.info('execute: elicitation fallback required', {
          elicitId: error.elicitId,
          toolName: error.toolName,
        });

        // Store pending fallback context in the elicitation store
        const authInfo = this.state.authInfo;
        const authInfoWithTransport = authInfo as (AuthInfo & TransportExtension) | undefined;
        const sessionId = authInfo?.sessionId ?? 'anonymous';
        const store = this.scope.elicitationStore;
        if (!store) {
          throw new InternalMcpError('Elicitation store not initialized');
        }

        await store.setPendingFallback({
          elicitId: error.elicitId,
          sessionId,
          toolName: error.toolName,
          toolInput: error.toolInput,
          elicitMessage: error.elicitMessage,
          elicitSchema: error.schema,
          createdAt: Date.now(),
          expiresAt: Date.now() + error.ttl,
        });

        // Determine transport type to choose the fallback strategy
        const transportType = authInfoWithTransport?.transport?.type;

        // Use waiting pattern only if:
        // 1. Transport is streamable-http (supports keeping connection open)
        // 2. Notifications can be delivered (session is registered in NotificationService)
        // Some LLMs don't support MCP notifications, so we need to fall back to fire-and-forget
        if (transportType === 'streamable-http' && canDeliverNotifications(this.scope as Scope, sessionId)) {
          // Waiting mode: Send notification + wait for pub/sub result
          // This keeps the request open and returns the actual tool result
          // when sendElicitationResult is called
          this.logger.info('execute: using waiting fallback for streamable-http', {
            elicitId: error.elicitId,
          });
          const deps: FallbackHandlerDeps = { scope: this.scope as Scope, sessionId, logger: this.logger };
          const result = await handleWaitingFallback(deps, error);
          toolContext.output = result;
          this.logger.verbose('execute:done (elicitation waiting fallback)');
          return;
        }

        // Fire-and-forget mode for SSE and other transports
        // Return fallback instructions immediately so the LLM can call sendElicitationResult
        // Set output to fallback response (not an error)
        // This structured response tells the LLM how to continue
        const fallbackResponse: ElicitationFallbackResponse = {
          content: [
            {
              type: 'text',
              text:
                `This tool requires user input to continue.\n\n` +
                `**Question:** ${error.elicitMessage}\n\n` +
                `After collecting the user's response, call the \`sendElicitationResult\` tool with:\n` +
                `- elicitId: "${error.elicitId}"\n` +
                `- action: "accept", "cancel", or "decline"\n` +
                `- content: (the user's response matching the schema below)\n\n` +
                `**Expected Response Schema:**\n\`\`\`json\n${JSON.stringify(error.schema, null, 2)}\n\`\`\``,
            },
          ],
          _meta: {
            elicitationPending: {
              elicitId: error.elicitId,
              message: error.elicitMessage,
              schema: error.schema,
              instructions: 'Call sendElicitationResult tool after collecting user input',
            },
          },
        };
        toolContext.output = fallbackResponse;

        this.logger.verbose('execute:done (elicitation fire-and-forget fallback)');
        return;
      }

      this.logger.error('execute: tool execution failed', error);
      throw new ToolExecutionError(
        this.state.tool?.metadata.name || 'unknown',
        error instanceof Error ? error : undefined,
      );
    }
  }

  @Stage('validateOutput')
  async validateOutput() {
    this.logger.verbose('validateOutput:start');
    const { toolContext } = this.state;
    if (!toolContext) {
      return;
    }
    toolContext.mark('validateOutput');

    // Store the RAW output for plugins (cache, PII, etc.) to inspect
    this.state.set('rawOutput', toolContext.output);

    this.logger.verbose('validateOutput:done');
  }

  @Stage('releaseSemaphore')
  async releaseSemaphore() {
    this.logger.verbose('releaseSemaphore:start');
    const ticket = this.state.semaphoreTicket as SemaphoreTicket | undefined;
    if (ticket) {
      try {
        await ticket.release();
        this.logger.verbose('releaseSemaphore: slot released');
      } catch (error) {
        this.logger.warn('releaseSemaphore: failed to release slot', error);
      }
    }
    this.state.toolContext?.mark('releaseSemaphore');
    this.logger.verbose('releaseSemaphore:done');
  }

  @Stage('releaseQuota')
  async releaseQuota() {
    this.logger.verbose('releaseQuota:start');
    // release rate limiting
    this.state.toolContext?.mark('releaseQuota');
    this.logger.verbose('releaseQuota:done');
  }

  /**
   * Apply UI rendering to the tool response.
   * This stage handles all UI-related logic including platform detection,
   * serving mode resolution, and content formatting.
   */
  @Stage('applyUI')
  async applyUI() {
    this.logger.verbose('applyUI:start');
    const { tool, rawOutput, input } = this.state;

    // Skip UI for agent tool calls (structured data only)
    const ctx = this.input.ctx;
    if (ctx?._skipUI) {
      this.logger.verbose('applyUI:skip (agent context - structured data only)');
      return;
    }

    // Skip if no tool or no UI config
    if (!tool || !hasUIConfig(tool.metadata)) {
      this.logger.verbose('applyUI:skip (no UI config)');
      return;
    }

    try {
      const scope = this.scope;

      // Get session info for platform detection from authInfo (already in state from parseInput)
      const { authInfo } = this.state;
      const sessionId = authInfo?.sessionId;
      const requestId: string = randomUUID();

      // Get platform type: first check sessionIdPayload (detected from user-agent),
      // then fall back to notification service (detected from MCP clientInfo),
      // finally default to 'unknown' (conservative: skip UI for unknown clients)
      const platformType =
        authInfo?.sessionIdPayload?.platformType ??
        (sessionId ? scope.notifications.getPlatformType(sessionId) : undefined) ??
        'unknown';

      // Resolve the effective serving mode based on configuration and client capabilities
      // Default is 'auto' which selects the best mode for the platform
      const configuredMode = tool.metadata.ui?.servingMode ?? 'auto';
      const resolvedMode = resolveServingMode({
        configuredMode,
        platformType,
      });

      // If client doesn't support UI (e.g., Gemini, unknown, or forced mode not available)
      // skip UI rendering entirely
      if (!resolvedMode.supportsUI || resolvedMode.effectiveMode === null) {
        this.logger.verbose('applyUI: Skipping UI (client does not support it)', {
          tool: tool.metadata.name,
          platform: platformType,
          configuredMode,
          reason: resolvedMode.reason,
        });
        return;
      }

      const servingMode = resolvedMode.effectiveMode;
      const useStructuredContent = resolvedMode.useStructuredContent;
      let htmlContent: string | undefined;
      let uiMeta: Record<string, unknown> = {};

      if (!scope.toolUI) {
        this.logger.verbose('applyUI: toolUI not available, skipping UI rendering');
        return;
      }

      if (servingMode === 'static') {
        // For static mode: no additional rendering needed
        // Widget was already registered at server startup
        this.logger.verbose('applyUI: UI using static mode (structured data only)', {
          tool: tool.metadata.name,
          platform: platformType,
        });
      } else if (servingMode === 'hybrid') {
        // For hybrid mode: build the component payload
        const componentPayload = scope.toolUI.buildHybridComponentPayload({
          toolName: tool.metadata.name,
          template: tool.metadata.ui?.template,
          uiConfig: tool.metadata.ui,
        });

        if (componentPayload) {
          uiMeta = {
            'ui/component': componentPayload,
            'ui/type': componentPayload['type'],
            'ui/mimeType': MCP_APPS_MIME_TYPE,
          };
        }

        this.logger.verbose('applyUI: UI using hybrid mode (structured data + component)', {
          tool: tool.metadata.name,
          platform: platformType,
          hasComponent: !!componentPayload,
          componentType: componentPayload?.['type'],
          componentHash: componentPayload?.['hash'],
        });
      } else {
        // For inline mode (default): render HTML with data embedded
        const uiRenderResult = await scope.toolUI.renderAndRegisterAsync({
          toolName: tool.metadata.name,
          requestId,
          input:
            input?.arguments && typeof input.arguments === 'object' && !Array.isArray(input.arguments)
              ? (input.arguments as Record<string, unknown>)
              : {},
          output: rawOutput,
          structuredContent: undefined,
          uiConfig: tool.metadata.ui,
          platformType,
        });

        // Handle graceful degradation: rendering failed in production
        if (isUIRenderFailure(uiRenderResult)) {
          this.logger.warn('applyUI: UI rendering failed (graceful degradation)', {
            tool: tool.metadata.name,
            reason: uiRenderResult.reason,
            platform: platformType,
          });
          // Proceed without UI - tool result will not have ui/html metadata
          htmlContent = undefined;
          uiMeta = {};
        } else {
          // Extract HTML from platform-specific meta key
          const htmlKey = 'ui/html';
          htmlContent = uiRenderResult?.meta?.[htmlKey] as string | undefined;
          // Fallback to ui/html for compatibility
          if (!htmlContent) {
            htmlContent = uiRenderResult?.meta?.['ui/html'] as string | undefined;
          }
          uiMeta = uiRenderResult.meta || {};
        }
      }

      // Build the response content using the extracted utility
      const uiResult = buildToolResponseContent({
        rawOutput,
        htmlContent,
        servingMode,
        useStructuredContent,
        platformType,
      });

      // Store UI result and metadata in state for finalize stage
      this.state.set('uiResult', uiResult);
      this.state.set('uiMeta', uiMeta);

      this.logger.verbose('applyUI: UI processed', {
        tool: tool.metadata.name,
        platform: platformType,
        servingMode,
        useStructuredContent,
        format: uiResult.format,
        contentCleared: uiResult.contentCleared,
      });
    } catch (error) {
      // UI rendering failure should not fail the tool call
      const errorMessage = error instanceof Error ? error.message : String(error);
      const errorStack = error instanceof Error ? error.stack : undefined;
      const uiConfig = tool.metadata.ui;

      this.logger.error('applyUI: UI rendering failed', {
        tool: tool.metadata.name,
        error: errorMessage,
        stack: errorStack,
        templateType: uiConfig?.template
          ? typeof uiConfig.template === 'function'
            ? 'react-component'
            : typeof uiConfig.template === 'string'
              ? uiConfig.template.endsWith('.tsx') || uiConfig.template.endsWith('.jsx')
                ? 'react-file'
                : 'html-file'
              : 'unknown'
          : 'none',
      });

      // In debug mode, also log to console for immediate visibility
      if (isDebug() || isDevelopment()) {
        console.error('[FrontMCP] UI Rendering Error:', {
          tool: tool.metadata.name,
          error: errorMessage,
          stack: errorStack,
        });
      }
    }

    this.logger.verbose('applyUI:done');
  }

  /**
   * Finalize the tool response.
   * Validates output, applies UI result from applyUI stage, and sends the response.
   *
   * Note: This stage runs even when execute fails (as part of cleanup).
   * If rawOutput is undefined, it means an error occurred during execution
   * and the error will be propagated by the flow framework - we should not
   * throw a new error here.
   */
  @Stage('finalize')
  async finalize() {
    this.logger.verbose('finalize:start');
    const { tool, rawOutput, uiResult, uiMeta } = this.state;

    if (!tool) {
      // No tool found - this is an early failure, just skip finalization
      this.logger.verbose('finalize: skipping (no tool in state)');
      return;
    }

    if (rawOutput === undefined) {
      // No output means execute stage failed - skip finalization
      // The original error will be propagated by the flow framework
      this.logger.verbose('finalize: skipping (no output - execute stage likely failed)');
      return;
    }

    // Parse and construct the MCP-compliant output using safeParseOutput
    const parseResult = tool.safeParseOutput(rawOutput);

    if (!parseResult.success) {
      this.logger.error('finalize: output validation failed', {
        tool: tool.metadata.name,
        errors: parseResult.error,
      });
      throw new InvalidOutputError();
    }

    // Reject non-finite numbers (Infinity / -Infinity / NaN) — JSON.stringify
    // silently coerces them to `null`, which Zod's `z.number()` accepts as
    // valid input but produces corrupted output for downstream consumers.
    // Opt-out via `@FrontMcp({ output: { allowNonFinite: true } })`.
    const allowNonFinite = (this.scope.metadata as { output?: { allowNonFinite?: boolean } }).output?.allowNonFinite;
    if (allowNonFinite !== true) {
      const found = findNonFiniteNumber(parseResult.data);
      if (found) {
        this.logger.error('finalize: non-finite number in tool output', {
          tool: tool.metadata.name,
          path: found.path,
          value: String(found.value),
        });
        throw new InvalidOutputError({
          reason: 'non-finite-number',
          path: found.path,
          value: String(found.value),
        });
      }
    }

    const result = parseResult.data;

    // Preserve any _meta from rawOutput (e.g., cache plugin adds cache: 'hit')
    // Runtime type guard to safely extract _meta from rawOutput
    const rawMeta = (() => {
      if (typeof rawOutput !== 'object' || rawOutput === null) return undefined;
      const meta = (rawOutput as Record<string, unknown>)['_meta'];
      if (typeof meta !== 'object' || meta === null) return undefined;
      return meta as Record<string, unknown>;
    })();
    if (rawMeta) {
      result._meta = { ...result._meta, ...rawMeta };
    }

    // Apply UI result if available (from applyUI stage)
    if (uiResult) {
      result.content = uiResult.content;
      // Set structuredContent from UI result (contains raw tool output)
      // Cast to Record<string, unknown> since MCP protocol expects object type
      if (uiResult.structuredContent !== undefined && uiResult.structuredContent !== null) {
        result.structuredContent = uiResult.structuredContent as Record<string, unknown>;
      }
      if (uiMeta) {
        result._meta = { ...result._meta, ...uiMeta };
      }
    }

    // Log the final result being sent
    this.logger.info('finalize: sending response', {
      tool: tool.metadata.name,
      hasContent: Array.isArray(result.content) && result.content.length > 0,
      contentParts: Array.isArray(result.content) ? result.content.length : 0,
      contentBytes: Array.isArray(result.content)
        ? result.content.reduce((sum, part) => {
            const str = JSON.stringify(part);
            return (
              sum +
              (typeof Buffer !== 'undefined'
                ? Buffer.byteLength(str, 'utf8')
                : new TextEncoder().encode(str).byteLength)
            );
          }, 0)
        : 0,
      hasStructuredContent: result.structuredContent !== undefined,
      hasMeta: result._meta !== undefined,
      metaKeys: result._meta ? Object.keys(result._meta) : [],
      isError: result.isError,
    });

    // Respond with the properly formatted MCP result
    this.respond(result);
    this.logger.verbose('finalize:done');
  }
}
