/**
 * @fileoverview Handler factory for tool definitions.
 * Constructs Context, checks inline auth, measures execution, formats response.
 * @module src/mcp-server/tools/utils/toolHandlerFactory
 */
import { toJSONSchema, ZodError } from 'zod';
import { config } from '../../../config/index.js';
import { attachTypedFail, createContext, readContentStore, readEnrichmentStore, } from '../../../core/context.js';
import { buildRequestScopedNotifiers } from '../../../mcp-server/notifications.js';
import { withRequiredScopes } from '../../../mcp-server/transports/auth/lib/authUtils.js';
import { McpError } from '../../../types-global/errors.js';
import { ErrorHandler } from '../../../utils/internal/error-handler/errorHandler.js';
import { measureToolExecution } from '../../../utils/internal/performance.js';
import { requestContextService } from '../../../utils/internal/requestContext.js';
import { ATTR_MCP_TOOL_ENRICHED } from '../../../utils/telemetry/attributes.js';
// ---------------------------------------------------------------------------
// Default formatter
// ---------------------------------------------------------------------------
const defaultResponseFormatter = (result) => [
    { type: 'text', text: JSON.stringify(result, null, 2) },
];
// ---------------------------------------------------------------------------
// Error response shaping
// ---------------------------------------------------------------------------
/**
 * Pulls `data.recovery.hint` from an error data payload when present and
 * non-empty. Returns `undefined` otherwise. Used to mirror the hint into
 * `content[]` text so format()-only clients see the same recovery guidance
 * structuredContent-only clients receive via `error.data.recovery.hint`.
 */
function extractRecoveryHint(data) {
    const hint = data?.recovery?.hint;
    return typeof hint === 'string' && hint.length > 0 ? hint : undefined;
}
/**
 * Shapes a `CallToolResult` for a tool error response with parity across both
 * surfaces clients forward to the agent:
 * - `content[]` — read by clients like Claude Desktop (markdown)
 * - `structuredContent.error` — read by clients like Claude Code (JSON)
 *
 * Both surfaces carry the same payload. When `data.recovery.hint` is present,
 * it is also mirrored into the `content[]` text so format()-only clients see
 * the recovery guidance.
 *
 * Note: `_meta.error` is intentionally NOT emitted — the error code, message,
 * and data live on `structuredContent.error` instead, mirroring the success
 * path's `structuredContent` surface.
 */
export function buildToolErrorResult(code, message, data) {
    const hint = extractRecoveryHint(data);
    const text = hint ? `Error: ${message}\n\nRecovery: ${hint}` : `Error: ${message}`;
    return {
        isError: true,
        content: [{ type: 'text', text }],
        structuredContent: {
            error: {
                code,
                message,
                ...(data !== undefined && { data }),
            },
        },
    };
}
/**
 * Builds an error `CallToolResult` from a raw thrown value. Classifies via
 * {@link ErrorHandler.classifyOnly} when the value isn't already an
 * `McpError`. Only propagates data from `McpError` (its declared `data`) or
 * `ZodError` (its `issues`) — other thrown values get a `structuredContent.error`
 * with `code` and `message` only, so internal classification context never
 * leaks to clients.
 *
 * Use after invoking {@link ErrorHandler.handleError} for OTel/logging side
 * effects — this helper does not log.
 */
export function classifyAndBuildToolErrorResult(error) {
    if (error instanceof McpError) {
        return buildToolErrorResult(error.code, error.message, error.data);
    }
    const { code, message } = ErrorHandler.classifyOnly(error);
    const data = error instanceof ZodError ? { issues: error.issues } : undefined;
    return buildToolErrorResult(code, message, data);
}
// ---------------------------------------------------------------------------
// Success response shaping — enrichment merge + trailer
// ---------------------------------------------------------------------------
/**
 * The output schema advertised to clients in `tools/list`. When a tool declares
 * an `enrichment` block, that's `output.extend(enrichment)` so enrichment fields
 * appear in the advertised `outputSchema`; otherwise the bare `output`.
 */
export function effectiveOutputSchema(def) {
    if (!def.enrichment)
        return def.output;
    return def.output.extend(def.enrichment);
}
/** Renders a non-kind-tagged enrichment value as compact trailer text. */
function formatEnrichmentScalar(value) {
    if (value == null)
        return String(value);
    if (typeof value === 'object')
        return JSON.stringify(value);
    return String(value);
}
/**
 * Renders accumulated enrichment as a single `content[]` trailer block, so
 * `content[]`-only clients see the same context `structuredContent` clients get
 * from the merged output. Per field, rendering resolves in order:
 *   1. a per-field `enrichmentTrailer.render` (the author's full control; wins
 *      over everything — the escape hatch for structured fields that would
 *      otherwise JSON-blob),
 *   2. the kind-tag set by a field-helper (notice → blockquote, total →
 *      "N total", echo → "Query: …", delta → "field: before → after"),
 *   3. the generic `**key:** value` fallback (key overridable via `label`),
 *      `JSON.stringify`-ing non-scalars.
 *
 * `parsed` is the validated effective output, so renderers receive the typed,
 * post-parse value rather than the raw accumulator entry. The rendered markdown
 * is appended to `content[]` only — `structuredContent` is built separately from
 * the raw merged values and never carries this trailer text. The trailer text
 * leads with a blank-line separator so it stands off the preceding block on
 * clients that concatenate adjacent text blocks with no join (#257); markdown
 * collapses consecutive blank lines, so clients that do insert their own
 * separator render at most one blank line either way. Returns `[]` when
 * nothing was enriched.
 */
function renderEnrichmentTrailer(store, trailer, parsed) {
    const lines = [];
    for (const key of Object.keys(store.values)) {
        // Skip keys the effective-output parse stripped (enriched but not declared in
        // the block) — the trailer mirrors what reached structuredContent.
        if (!(key in parsed))
            continue;
        const value = parsed[key];
        const cfg = trailer?.[key];
        if (cfg?.render) {
            lines.push(cfg.render(value));
            continue;
        }
        switch (store.kinds.get(key)) {
            case 'notice':
                lines.push(`> ${String(value)}`);
                break;
            case 'total':
                lines.push(`**${String(value)} total**`);
                break;
            case 'echo':
                lines.push(`Query: ${String(value)}`);
                break;
            case 'delta': {
                const d = (value ?? {});
                lines.push(`**${cfg?.label ?? key}:** ${formatEnrichmentScalar(d.before)} → ${formatEnrichmentScalar(d.after)}`);
                break;
            }
            default:
                lines.push(`**${cfg?.label ?? key}:** ${formatEnrichmentScalar(value)}`);
        }
    }
    return lines.length > 0 ? [{ type: 'text', text: `\n\n${lines.join('\n')}` }] : [];
}
/**
 * Shapes the success `CallToolResult` surfaces from the validated domain payload
 * plus any accumulated enrichment:
 * - `structuredContent` — domain output merged with enrichment (validated against
 *   `output.extend(enrichment)`); the bare domain output when no enrichment block.
 * - `content[]` — the caller-rendered domain content (from `format()` or the
 *   JSON-stringify default, applied to the **domain payload only**) with the
 *   enrichment trailer always appended. Rendering the domain payload — never the
 *   merged object — keeps enrichment out of the JSON blob and avoids double-render.
 *
 * A required enrichment field the handler never populated fails the parse here,
 * surfacing the authoring bug as a loud error rather than dropping it silently.
 */
export function buildToolSuccessResult(def, ctx, domainValidated, domainContent) {
    if (!def.enrichment) {
        return { structuredContent: domainValidated, content: domainContent };
    }
    const store = readEnrichmentStore(ctx);
    const values = store?.values ?? {};
    const structuredContent = effectiveOutputSchema(def).parse({
        ...domainValidated,
        ...values,
    });
    const trailer = store && Object.keys(values).length > 0
        ? renderEnrichmentTrailer(store, def.enrichmentTrailer, structuredContent)
        : [];
    return {
        structuredContent,
        content: trailer.length > 0 ? [...domainContent, ...trailer] : domainContent,
    };
}
// ---------------------------------------------------------------------------
// Capability detection helpers
// ---------------------------------------------------------------------------
/**
 * Builds `ctx.elicit` from the notifiers bound at registration time.
 *
 * Returns `undefined` when:
 * - `elicitInput` was not bound (auto-task background path, or no server),
 * - the client did not advertise the elicitation capability (checked at
 *   request time, after the initialize handshake populates capabilities).
 *
 * When defined, the returned function is an `ElicitFn`: directly callable for
 * form-mode elicitation (schema converted from ZodObject to the restricted
 * flat JSON Schema the MCP spec requires), plus a `.url(message, url)` helper
 * for URL-mode elicitation (elicitationId generated internally).
 *
 * CRITICAL: `requestedSchema` must be a plain JSON Schema object in the spec's
 * restricted flat form — passing a raw ZodObject causes AJV validation to
 * reject the request on the SDK side. `z.toJSONSchema(schema)` produces the
 * correct shape.
 */
function wrapElicit(notifiers) {
    const { elicitInput, getClientCapabilities } = notifiers;
    if (typeof elicitInput !== 'function')
        return;
    // Capability check runs at request time (not registration time) so that
    // capabilities populated during the initialize handshake are visible.
    if (!getClientCapabilities?.()?.elicitation)
        return;
    const formFn = (msg, schema) => {
        const requestedSchema = toJSONSchema(schema);
        return elicitInput({ message: msg, requestedSchema });
    };
    const urlFn = (msg, url) => {
        const elicitationId = crypto.randomUUID();
        return elicitInput({ mode: 'url', message: msg, elicitationId, url });
    };
    const elicitFn = formFn;
    elicitFn.url = urlFn;
    return elicitFn;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
 * Creates an MCP tool handler from a tool definition.
 * The returned function is compatible with the MCP SDK's ToolCallback type.
 *
 * Responsibilities:
 * - Creates RequestContext from SDK context (for tracing)
 * - Creates unified Context (for the handler)
 * - Checks inline `auth` scopes if defined
 * - Validates input via Zod schema
 * - Measures execution time
 * - Formats response via `format` or JSON default
 * - Catches errors and returns `isError: true`
 */
export function createToolHandler(def, services, notifiers) {
    const formatter = def.format ?? defaultResponseFormatter;
    return async (input, callContext) => {
        // The SDK types `extra` as Record<string, unknown> at the boundary
        const sdkContext = callContext;
        // Route handler-time list-changed / resource-updated notifications through
        // this request's `extra.sendNotification` so they carry `relatedRequestId`
        // and reach the client under the per-request HTTP/Worker McpServer model
        // (#135). Fall back to the server-level notifiers when the extra exposes no
        // sender (non-request scopes) — those deliver on stdio but drop under HTTP.
        const effectiveNotifiers = buildRequestScopedNotifiers(sdkContext) ?? notifiers;
        const sdkSessionId = typeof sdkContext?.sessionId === 'string' ? sdkContext.sessionId : undefined;
        // Surface sessionId on `Context` only when it has request-spanning
        // lifetime — stateful HTTP (or `auto`, which resolves to stateful for
        // HTTP). In stateless mode the SDK still hands us a per-request token;
        // pass it through only when the consumer opted in via
        // `createApp({ context: { exposeStatelessSessionId: true } })`. Stdio
        // gives no sessionId at the SDK layer, so the gate is moot there.
        const isStatefulMode = config.mcpSessionMode === 'stateful' || config.mcpSessionMode === 'auto';
        const handlerSessionId = sdkSessionId && (isStatefulMode || services.exposeStatelessSessionId === true)
            ? sdkSessionId
            : undefined;
        // Create internal RequestContext for tracing. Raw `input` is intentionally
        // excluded — it flows into the completion log via context spread and can
        // contain caller PII or secrets. Input size and top-level parameter names
        // are captured as OTel metric attributes in measureToolExecution instead.
        // Log correlation always uses the raw SDK sessionId — useful even in
        // stateless mode for tracing the SDK's per-request token through events.
        const appContext = requestContextService.createRequestContext({
            parentContext: {
                ...(typeof sdkContext?.requestId === 'string' ? { requestId: sdkContext.requestId } : {}),
                ...(sdkSessionId ? { sessionId: sdkSessionId } : {}),
            },
            operation: 'HandleToolRequest',
            additionalContext: { toolName: def.name, sessionId: sdkSessionId },
        });
        try {
            // Check inline auth scopes
            if (def.auth && def.auth.length > 0) {
                withRequiredScopes(def.auth, appContext);
            }
            // Validate input
            const validatedInput = def.input.parse(input);
            // Construct Context with detected capabilities. When the definition
            // declares an error contract, `attachTypedFail` adds `ctx.fail` so
            // handlers can `throw ctx.fail('reason', ...)`; otherwise ctx is unchanged.
            const ctx = attachTypedFail(createContext({
                appContext,
                logger: services.logger,
                storage: services.storage,
                signal: sdkContext.signal,
                sessionId: handlerSessionId,
                elicit: wrapElicit(notifiers),
                notifyPromptListChanged: effectiveNotifiers.notifyPromptListChanged,
                notifyResourceListChanged: effectiveNotifiers.notifyResourceListChanged,
                notifyResourceUpdated: effectiveNotifiers.notifyResourceUpdated,
                notifyToolListChanged: effectiveNotifiers.notifyToolListChanged,
            }), def.errors);
            // Execute handler with performance measurement.
            // Wrap with Promise.resolve — handler may return sync or async.
            const result = await measureToolExecution(() => Promise.resolve(def.handler(validatedInput, ctx)), { ...appContext, toolName: def.name }, validatedInput, () => {
                const store = readEnrichmentStore(ctx);
                return store && Object.keys(store.values).length > 0
                    ? { [ATTR_MCP_TOOL_ENRICHED]: true }
                    : {};
            });
            const validatedResult = def.output.parse(result);
            // Render content[] from the domain payload only (Resolution B). Isolate
            // formatter errors from handler errors so they get classified correctly.
            let domainContent;
            try {
                domainContent = formatter(validatedResult);
            }
            catch (formatError) {
                throw new Error(`Output formatting failed: ${formatError instanceof Error ? formatError.message : String(formatError)}`);
            }
            // Prepend any blocks collected via `ctx.content` (image/audio bytes). They
            // ride content[] only — never structuredContent — so a handler can surface
            // media for the model without the base64 duplicating into the typed output.
            // Empty for tools that never call ctx.content, leaving content[] unchanged.
            const collected = readContentStore(ctx)?.blocks;
            if (collected && collected.length > 0) {
                domainContent = [...collected, ...domainContent];
            }
            // Merge enrichment into structuredContent and append the content[] trailer.
            return buildToolSuccessResult(def, ctx, validatedResult, domainContent);
        }
        catch (error) {
            ErrorHandler.handleError(error, {
                operation: `tool:${def.name}`,
                context: appContext,
            });
            return classifyAndBuildToolErrorResult(error);
        }
    };
}
//# sourceMappingURL=toolHandlerFactory.js.map