import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createEntitySession } from '../code/entity/session.js';
import { blameInputShape, blameOutputSchema, cochangeInputShape, cochangeOutputSchema, contextInputShape, contextOutputSchema, docInputShape, docOutputSchema, anchorInputShape, anchorOutputSchema, sourceInputShape, sourceOutputSchema, patternInputShape, patternOutputSchema, diffInputShape, diffOutputSchema, entitiesInputShape, entitiesOutputSchema, hotspotsInputShape, hotspotsOutputSchema, impactInputShape, impactOutputSchema, logInputShape, logOutputSchema, statusInputShape, statusOutputSchema, runBlame, runCochange, runContext, runDoc, runAnchor, runSource, runPattern, runDiff, runEntities, runHotspots, runImpact, runLog, runStatus, guardPath, isToolOutcome, withOccMcpToolExamples, } from './tools.js';
import { OccAbortError, isInvalidInputError, isOccAbortError } from '../errors.js';
import { isGitError } from '../code/entity/git-error.js';
const SERVER_NAME = 'occ';
function log(message) {
    process.stderr.write(`occ mcp: ${message}\n`);
}
async function resolveVersion() {
    const here = path.dirname(fileURLToPath(import.meta.url));
    // Two levels up from src/mcp/ in dev, three from dist/src/mcp/ when built.
    // This module sits one directory deeper than src/cli.ts, so it needs the
    // extra candidate that file does not.
    for (const rel of ['../..', '../../..', '..']) {
        try {
            const raw = await readFile(path.resolve(here, rel, 'package.json'), 'utf8');
            const parsed = JSON.parse(raw);
            if (parsed.version)
                return parsed.version;
        }
        catch {
            // Try the next candidate.
        }
    }
    return '0.0.0';
}
/**
 * The shared session, built once, kept current, and abandoned when nobody wants it.
 *
 * Building it walks history and indexes the repository, which can take seconds
 * on a large repo — far longer than a client will wait for `initialize`. So it
 * is built on the *first tool call* instead, and memoized as a promise so that
 * concurrent first calls share one build rather than racing two.
 *
 * A failed build is not cached: the usual cause is a transient one (a git
 * operation mid-flight, a repository being cloned), and a client that retries
 * should get a real second attempt rather than a replayed error.
 *
 * Two properties beyond memoization:
 *
 * **Cancellation is reference-counted.** A request's signal cannot simply be
 * passed to the build, because the build is shared: one client cancelling would
 * abandon work another client is still waiting on. Instead every waiter is
 * counted, and the build is aborted only when the last of them leaves — at
 * which point finishing it would be work for nobody.
 *
 * **Freshness is re-checked, not assumed.** A long-lived server outlives the
 * commits and edits made under it. Before each call the session is asked
 * whether it still describes the repository — one `rev-parse` plus a manifest
 * stat-diff — and reindexed when it does not. Concurrent callers share one
 * reconcile; a failed check is logged and the existing session is used, because
 * a possibly-stale answer beats no answer.
 */
export function createSessionHolder(options, lifetime) {
    let pending;
    let settled;
    let building;
    // Identifies which build a waiter is attached to, so a retired build's late
    // rejection cannot clear the one that replaced it.
    let generation = 0;
    let waiting = 0;
    let reconciling;
    const progressSubscribers = new Set();
    const broadcastProgress = event => {
        for (const subscriber of progressSubscribers) {
            try {
                subscriber(event);
            }
            catch (error) {
                log(`progress subscriber failed: ${error instanceof Error ? error.message : String(error)}`);
            }
        }
    };
    /**
     * Retire the in-flight build so the next caller starts a fresh one.
     *
     * Generation-guarded because a retired build still rejects afterwards, and
     * that late rejection must not clear a newer build that has already taken
     * its place.
     */
    function retire(which) {
        if (which !== generation)
            return;
        generation++;
        pending = undefined;
        building = undefined;
    }
    function start() {
        if (pending)
            return { build: pending, generation };
        const mine = generation;
        const controller = new AbortController();
        building = controller;
        // The server shutting down abandons the build too, whoever is waiting.
        // Removed when the build settles: a client retrying against a repository
        // that cannot be indexed would otherwise leave one listener per attempt.
        const abandonOnShutdown = () => controller.abort();
        lifetime.addEventListener('abort', abandonOnShutdown, { once: true });
        if (lifetime.aborted)
            controller.abort();
        pending = options
            .build(controller.signal, broadcastProgress)
            .then(session => {
            // Dropped here rather than only in the `.finally`, which is a microtask
            // later: the session's signal is this controller, so a shutdown landing
            // in that gap would abort a session that has already been adopted.
            lifetime.removeEventListener('abort', abandonOnShutdown);
            // A build that was abandoned must not be adopted, and this cannot be
            // left to the build itself to enforce: session construction ends in a
            // deliberately abort-deaf tail (format discovery, untracked probing),
            // so an abort landing there produces a *resolved* session rather than a
            // rejection. Adopting it would install a session whose own signal is
            // already aborted — every later query would then fail or, worse, come
            // back empty — and nothing ever clears `settled`, so the damage would
            // last for the life of the process.
            if (mine !== generation || controller.signal.aborted) {
                void Promise.resolve(session.dispose()).catch(() => { });
                throw new OccAbortError();
            }
            settled = session;
            building = undefined;
            return session;
        })
            .catch((error) => {
            retire(mine);
            throw error;
        })
            .finally(() => {
            lifetime.removeEventListener('abort', abandonOnShutdown);
        });
        // The abandoning waiter has usually stopped awaiting this promise by the
        // time it settles, so without a detached handler its rejection would be an
        // unhandled rejection and take the process down.
        void pending.catch(() => { });
        return { build: pending, generation: mine };
    }
    /** Bring the session back in line with the repository, at most once at a time. */
    function reconcile(session) {
        if (options.refresh === false)
            return Promise.resolve();
        if (reconciling)
            return reconciling;
        reconciling = (async () => {
            try {
                const freshness = await session.freshness({ signal: lifetime });
                if (freshness.fresh)
                    return;
                log(`repository changed (${freshness.reasons.join(', ')}); reindexing`);
                await session.refresh({ signal: lifetime, onProgress: broadcastProgress });
            }
            catch (error) {
                // Never fatal. A stale answer the caller can still act on beats
                // failing a request because the freshness check itself broke.
                log(`freshness check failed: ${error instanceof Error ? error.message : String(error)}`);
            }
            finally {
                reconciling = undefined;
            }
        })();
        return reconciling;
    }
    return {
        async get(signal, onProgress) {
            if (onProgress)
                progressSubscribers.add(onProgress);
            let subscribed = onProgress !== undefined;
            const unsubscribe = () => {
                if (!subscribed || !onProgress)
                    return;
                subscribed = false;
                progressSubscribers.delete(onProgress);
            };
            try {
                if (settled) {
                    await reconcile(settled);
                    return settled;
                }
                const { build, generation: mine } = start();
                let counted = true;
                const leave = (abandonIfLast) => {
                    if (!counted)
                        return;
                    counted = false;
                    waiting--;
                    // `mine === generation` matters as much as the count: a waiter whose
                    // own build has already been retired is not attached to whatever is
                    // building now, and aborting that one would refuse a build for callers
                    // who never cancelled anything.
                    if (abandonIfLast && waiting === 0 && !settled && mine === generation) {
                        building?.abort();
                        // Retired here rather than when the rejection lands. A request
                        // arriving in that window would otherwise be handed the abandoned
                        // build and fail with a cancellation it never asked for.
                        retire(mine);
                    }
                };
                waiting++;
                const onAbort = () => {
                    leave(true);
                    unsubscribe();
                };
                signal.addEventListener('abort', onAbort, { once: true });
                try {
                    const session = await build;
                    await reconcile(session);
                    return session;
                }
                finally {
                    leave(false);
                    signal.removeEventListener('abort', onAbort);
                }
            }
            finally {
                unsubscribe();
            }
        },
        async dispose() {
            if (settled)
                await settled.dispose();
            settled = undefined;
            pending = undefined;
            building = undefined;
            progressSubscribers.clear();
        },
    };
}
/** Count cadence for deterministic cold-build progress; never time based. */
export const MCP_PROGRESS_COMMIT_INTERVAL = 10;
function createProgressForwarder(context) {
    const progressToken = context._meta?.progressToken;
    if (progressToken === undefined || !context.sendNotification)
        return undefined;
    let pending = Promise.resolve();
    let last = '';
    return {
        onProgress(event) {
            if (event.scope !== 'entity' || event.phase !== 'index' || event.total <= 0)
                return;
            const final = event.completed === event.total || / event\(s\)$/u.test(event.detail ?? '');
            if (!final && event.completed % MCP_PROGRESS_COMMIT_INTERVAL !== 0)
                return;
            const key = `${event.completed}/${event.total}`;
            if (key === last)
                return;
            last = key;
            pending = pending
                .then(() => context.sendNotification({
                method: 'notifications/progress',
                params: {
                    progressToken,
                    progress: event.completed,
                    total: event.total,
                    message: `${event.completed}/${event.total} commits indexed`,
                },
            }))
                .catch((error) => {
                log(`progress notification failed: ${error instanceof Error ? error.message : String(error)}`);
            });
        },
        flush: () => pending,
    };
}
/**
 * Turn a handler outcome into an MCP tool result.
 *
 * A `not_found` or an `ambiguous` is a truthful answer to a reasonable
 * question, so it is a normal result — same policy as the CLI, which exits 0
 * on a miss. `isError` is reserved for a request that could not be honoured at
 * all: an invalid argument, a path escape, a git failure.
 */
function hasErrorStatus(value) {
    return typeof value === 'object' && value !== null && 'status' in value && value.status === 'error';
}
/** Fields deliberately omitted only by the concise MCP projection. */
export const MCP_CONCISE_OMITTED_FIELDS = [
    'blobSha',
    'oldBlobSha',
    'newBlobSha',
    'blobOid',
    'oldBlobOid',
    'newBlobOid',
    'spanDigest',
    'span_digest',
    'exactSpanHash',
    'exact_span_hash',
    'spanHash',
    'spanSha256',
    'sourceSpanHash',
    'sourceSha256',
    'sha256',
    'provenance',
    'matchReason',
    'engine',
    'evidence',
    'hashes',
    'bodyHash',
    'exactBodyHash',
    'normalizedHash',
    'structuralHash',
    'signatureHash',
    'primaryHash',
    'commentsElidedHash',
];
const conciseOmittedFields = new Set(MCP_CONCISE_OMITTED_FIELDS);
const conciseProtectedSubtrees = new Set([
    'honesty',
    'coverage',
    'truncation',
    'freshness',
    'diagnostics',
    'evidenceGaps',
    // A captured anchor IS a hash tuple; stripping its members would return an
    // empty capture in exactly the mode a lightweight client uses.
    'anchor',
]);
const stableHandleFields = new Set(['nodeId', 'lineageId', 'entityId']);
function isJsonObject(value) {
    return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function conciseValue(value, protectedSubtree = false) {
    if (protectedSubtree)
        return value;
    if (Array.isArray(value))
        return value.map(element => conciseValue(element));
    if (!isJsonObject(value))
        return value;
    const handle = typeof value.nodeId === 'string'
        ? 'nodeId'
        : typeof value.lineageId === 'string'
            ? 'lineageId'
            : typeof value.entityId === 'string'
                ? 'entityId'
                : undefined;
    const projected = {};
    for (const [key, member] of Object.entries(value)) {
        if (stableHandleFields.has(key) && handle !== undefined && key !== handle)
            continue;
        // Lineage-match confidence is internal derivation evidence. A co-change
        // pair's public confidence ratio has no matchReason sibling and survives.
        if (key === 'confidence' && typeof value.matchReason === 'string')
            continue;
        if (conciseOmittedFields.has(key))
            continue;
        projected[key] = conciseValue(member, conciseProtectedSubtrees.has(key));
    }
    return projected;
}
/**
 * Apply the one wire-level response projection after a tool has finished.
 * Detailed is the compatibility path and deliberately returns the same object.
 */
export function projectMcpResponse(data, responseFormat = 'detailed') {
    return responseFormat === 'concise' ? conciseValue(data) : data;
}
export function toCallResult(outcome, responseFormat = 'detailed') {
    const data = projectMcpResponse(outcome.data, responseFormat);
    return {
        content: [{ type: 'text', text: outcome.text }],
        structuredContent: data,
        isError: outcome.isError === true || hasErrorStatus(data),
    };
}
function errorResult(message, code, responseFormat) {
    return toCallResult({
        text: `Error (${code}): ${message}`,
        data: { status: 'error', code, message },
        isError: true,
    }, responseFormat);
}
function classifyGuardError(error) {
    if (isOccAbortError(error))
        return 'aborted';
    if (isInvalidInputError(error))
        return 'invalid-input';
    if (isGitError(error))
        return 'git-failed';
    return 'git-failed';
}
/**
 * Run a tool body under the request's cancellation signal.
 *
 * MCP `notifications/cancelled` aborts `extra.signal`. The session's query
 * methods are synchronous graph walks over an already-built index, so there is
 * nothing to interrupt inside them; what cancellation buys there is a prompt,
 * typed response instead of a client waiting on work it no longer wants.
 *
 * Acquiring the session is raced too, not merely the query. On a cold start
 * that is where all the time goes, and a client that cancels during it should
 * hear back immediately rather than after the index it no longer wants
 * finishes. The holder decides whether the build itself is abandoned; that
 * depends on whether anyone else is still waiting, which is not this
 * function's to know.
 */
async function guarded(context, repoRoot, holder, bounds, responseFormat, relativePath, run) {
    if (context.signal.aborted)
        return errorResult('Request cancelled before it started.', 'aborted', responseFormat);
    // Before the session, deliberately: a path outside the root is refused
    // without indexing anything, so a hostile argument costs nothing and cannot
    // reach code that reads files.
    const confined = guardPath(repoRoot, relativePath);
    if (isToolOutcome(confined))
        return toCallResult(confined, responseFormat);
    const cancelled = new Promise(resolve => {
        if (context.signal.aborted)
            resolve('cancelled');
        else
            context.signal.addEventListener('abort', () => resolve('cancelled'), { once: true });
    });
    try {
        const progress = createProgressForwarder(context);
        const work = (async () => {
            const session = await holder.get(context.signal, progress?.onProgress);
            return run({ repoRoot, session, bounds }, confined.relativePath);
        })();
        const settled = await Promise.race([work, cancelled]);
        if (settled === 'cancelled') {
            // The abandoned build rejects with an abort error nobody is awaiting any
            // more; without this it would surface as an unhandled rejection and take
            // the process down.
            void work.catch(() => { });
            await progress?.flush();
            return errorResult('Request cancelled.', 'aborted', responseFormat);
        }
        await progress?.flush();
        return toCallResult(settled, responseFormat);
    }
    catch (error) {
        if (context.signal.aborted || isOccAbortError(error)) {
            return errorResult('Request cancelled.', 'aborted', responseFormat);
        }
        const message = error instanceof Error ? error.message : String(error);
        log(message);
        return errorResult(message, classifyGuardError(error), responseFormat);
    }
}
export async function createOccMcpServer(options) {
    const repoRoot = path.resolve(options.repoRoot);
    const lifetime = new AbortController();
    const holder = createSessionHolder({
        refresh: options.refresh,
        build: (signal, onProgress) => createEntitySession({
            repoRoot,
            cacheDir: options.cacheDir,
            excludeDir: options.excludeDir,
            tokenizer: options.tokenizer,
            typescriptChecker: options.typescriptChecker,
            tests: options.testsDir?.length ? { directories: options.testsDir } : undefined,
            formats: options.formats !== false,
            bounds: options.bounds,
            signal,
            onProgress,
        }),
    }, lifetime.signal);
    const server = new McpServer({ name: SERVER_NAME, version: options.version ?? (await resolveVersion()) }, {
        capabilities: { tools: {} },
        instructions: 'Entity-level semantic code intelligence for one repository: search, hotspots, semantic diff, ' +
            'impact with affected tests, per-entity blame, history across renames and moves, ' +
            'token-budgeted context bundles, targeted source reads (occ_source), and read-only structural ' +
            'pattern search (occ_pattern). ' +
            'Every tool answers about the repository this server ' +
            'was started against; paths outside it are refused.',
    });
    server.registerTool('occ_doc', {
        title: 'Read symbol contract',
        description: withOccMcpToolExamples('occ_doc', 'Read one symbol contract — signature, leading documentation, parameters, and body token ' +
            'price — without returning the body. Accepts a name or exact node id. Returns `CodeDocResult`.'),
        inputSchema: docInputShape,
        outputSchema: docOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runDoc(args, ctx, file)));
    server.registerTool('occ_pattern', {
        title: 'Match a structural pattern',
        description: withOccMcpToolExamples('occ_pattern', 'Read-only ast-grep pattern/rule matching over the live code index: code-shaped patterns with ' +
            '$META metavariables, or full rule objects with relational (inside/has) and composite ' +
            '(all/any/not) clauses. Matches join to indexed node ids where containment is unique. ' +
            'Returns `CodePatternResult`.'),
        inputSchema: patternInputShape,
        outputSchema: patternOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runPattern(args, ctx, file)));
    server.registerTool('occ_entities', {
        title: 'List entities',
        description: withOccMcpToolExamples('occ_entities', 'Search functions, classes, methods, and data-format keys by name, with the lineage id ' +
            'that identifies each across renames and moves. Returns an `OccEntitySearchResult` honesty ' +
            'envelope plus the backward-compatible returned `count`.'),
        inputSchema: entitiesInputShape,
        outputSchema: entitiesOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runEntities(args, ctx, file)));
    server.registerTool('occ_hotspots', {
        title: 'Rank entity hotspots',
        description: withOccMcpToolExamples('occ_hotspots', 'Rank current code entities by logic-changing lineage events multiplied by token count or line span. ' +
            'Cosmetic changes are reported separately, bounded-window births remain explicit, and optional ' +
            'PageRank weighting is mechanical. Returns `EntityHotspotResult`.'),
        inputSchema: hotspotsInputShape,
        outputSchema: hotspotsOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runHotspots(args, ctx, file)));
    server.registerTool('occ_cochange', {
        title: 'Entity temporal coupling',
        description: withOccMcpToolExamples('occ_cochange', 'Rank current code entities whose proved logic changes occur in the same commits, joined by ' +
            'rename-surviving lineage ids. Cosmetic edits and pure identity moves are filtered, broad ' +
            'commits are skipped, and confidence includes its change-count and commit-window denominators. ' +
            'Returns `EntityCochangeResult`.'),
        inputSchema: cochangeInputShape,
        outputSchema: cochangeOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runCochange(args, ctx, file)));
    server.registerTool('occ_diff', {
        title: 'Whole-diff and entity diff',
        description: withOccMcpToolExamples('occ_diff', 'Entity-level diff for a git range, staged changes, or the working tree. The ' +
            '`wholeDiffSemantic` field is the authoritative whole-diff proof; the legacy `semantic` ' +
            'field summarizes emitted entity records only and must not be used as a whole-file no-op gate. ' +
            'Under `whole-file-cst-v4`, `logic-change` proves non-trivia structural drift with a checked-in ' +
            'grammar manifest, while admitted ordinary-comment and safe-whitespace changes are `cosmetic-change` ' +
            'under a separate digest-pinned trivia policy. Directives, BOM transitions, invalid text, and unsafe ' +
            'language-specific whitespace remain `unknown`. Complete cosmetic-only scopes aggregate to `semantic-no-op`; ' +
            'this is token/CST evidence, not a claim of observational equivalence. Persisted V3 MCP receipts remain decodable ' +
            'and do not retroactively gain the V4 cosmetic bucket; public types retain V1-V3. ' +
            '`wholeDiffSemantic.gitScope` records the exact ' +
            'resolved physical Git scope. Classifies each entity ' +
            'change as added, modified, modified_logic, deleted, moved, renamed, ' +
            'reordered, or orphan, with the hash evidence behind it. Optional obligations add untested ' +
            'logic gaps, touched static seam keys, and a logic/cosmetic split. Persisted `modified_cosmetic`, ' +
            'file-level `semantic-no-op` is a decodable legacy arm. Returns `OccEntityDiffResult`.'),
        inputSchema: diffInputShape,
        outputSchema: diffOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runDiff(args, ctx, file)));
    server.registerTool('occ_impact', {
        title: 'Entity impact',
        description: withOccMcpToolExamples('occ_impact', 'What a change to one entity reaches: direct dependencies and dependents, transitive ' +
            'impact, and the tests that cover it — found by reverse call traversal, not by filename. ' +
            '`inline_context` adds verified surrounding source to each returned reference. Reports ' +
            '`coverage: "partial"` when the language yields no call edges. Returns ' +
            '`OccEntityImpactResult`.'),
        inputSchema: impactInputShape,
        outputSchema: impactOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runImpact(args, ctx, file)));
    server.registerTool('occ_blame', {
        title: 'Entity blame',
        description: withOccMcpToolExamples('occ_blame', 'One entry per entity in a file, attributed to the last change that altered its meaning ' +
            'rather than the newest commit touching any line in its range. Cosmetic edits, moves, and ' +
            'reorderings are skipped. States provenance honestly (committed, working-tree-modified, ' +
            'uncommitted, untracked, no-history, shallow-history) and never invents commit metadata. ' +
            'Returns `OccEntityBlameResult`.'),
        inputSchema: blameInputShape,
        outputSchema: blameOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runBlame(args, ctx, file)));
    server.registerTool('occ_log', {
        title: 'Entity history',
        description: withOccMcpToolExamples('occ_log', 'History for one entity across commits, renames, moves, and container changes, newest ' +
            'first. Each entry carries the change type, whether the change was structural, and the ' +
            'confidence and reason behind the lineage match. Returns `OccEntityHistoryResult`.'),
        inputSchema: logInputShape,
        outputSchema: logOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runLog(args, ctx, file)));
    server.registerTool('occ_context', {
        title: 'Entity context bundle',
        description: withOccMcpToolExamples('occ_context', 'A token-budgeted source bundle: the target entity first, then related dependencies and ' +
            'dependents. `inline_context` expands each body with verified surrounding lines and charges ' +
            'them to the same hard budget. Never exceeds that budget and never cuts an entity without ' +
            'saying so. If its empty envelope cannot fit, returns role-based narrowing argv with clearly ' +
            'labeled token projections. Reports reference closure over resolved 1-hop edges under the pinned index with ' +
            'the reasons, counts, and bounded handles behind closed, open, or unknown. `hops: 0` is unbounded. ' +
            'Returns `OccEntityContextResult`.'),
        inputSchema: contextInputShape,
        outputSchema: contextOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runContext(args, ctx, file)));
    server.registerTool('occ_status', {
        title: 'Index status and freshness',
        description: withOccMcpToolExamples('occ_status', 'What this index covers and whether it still describes the repository: mode, indexed head, ' +
            'commits, lineages, additive code-index/parser contracts, parser/checker degradation counts, ' +
            'whether bounds truncated it and why, working-tree drift, and the bounds in force. Every other ' +
            'tool can return nothing for two incompatible reasons — the repository ' +
            'has nothing, or the index was starved or has gone stale — and this is where they are told ' +
            'apart. Returns `OccEntityIndexStatus` plus `fresh`, `freshness`, and `bounds`.'),
        inputSchema: statusInputShape,
        outputSchema: statusOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, undefined, ctx => runStatus(args, ctx)));
    server.registerTool('occ_source', {
        title: 'Read one symbol body',
        description: withOccMcpToolExamples('occ_source', 'Read one symbol body verbatim by exact node id or by name, optionally narrowed to a ' +
            'repository-relative file. Ambiguous names are refused rather than guessed. Returns source, ' +
            'path, exact span evidence when available, token count, and freshness/coverage honesty as ' +
            '`OccEntitySourceResult`.'),
        inputSchema: sourceInputShape,
        outputSchema: sourceOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runSource(args, ctx, file)));
    server.registerTool('occ_anchor', {
        title: 'Capture a memory anchor',
        description: withOccMcpToolExamples('occ_anchor', 'Capture one entity as a verifiable memory anchor from a single session snapshot: its durable ' +
            'lineage id, symbol key, hash tuple, and — only when capture proves the pairing against the ' +
            'committed head record — a baseCommit. Verify stored anchors later with `occ code ' +
            'verify-anchors`. OCC never stores the anchor. Returns `CaptureEntityAnchorResult`.'),
        inputSchema: anchorInputShape,
        outputSchema: anchorOutputSchema,
        annotations: { readOnlyHint: true, openWorldHint: false },
    }, (args, extra) => guarded(extra, repoRoot, holder, options.bounds, args.response_format, args.relativePath, (ctx, file) => runAnchor(args, ctx, file)));
    return {
        server,
        dispose: async () => {
            lifetime.abort();
            await holder.dispose();
        },
    };
}
function requestId(message) {
    return 'id' in message ? message.id : undefined;
}
/**
 * Serve on stdio until the client disconnects, then exit.
 *
 * There is no idle timeout, no reconnect, and no daemon: the transport closing
 * is the end of the process's reason to exist. But "closing" has to mean
 * *finished*, not merely *no more input*. `StdioServerTransport` never closes
 * itself, so a piped invocation — `echo '…' | occ mcp` — would otherwise hang
 * forever; and tearing down the moment stdin ends would drop the answer to a
 * request already in flight.
 *
 * So shutdown waits for stdin to end *and* every outstanding request to be
 * accounted for. A request leaves the outstanding set when its response has
 * been flushed to stdout, or when the client cancels it — the SDK deliberately
 * sends no response for a cancelled request, so cancellation has to retire the
 * id too or the drain would never complete.
 */
export async function runMcpServer(options) {
    const { server, dispose } = await createOccMcpServer(options);
    const transport = new StdioServerTransport();
    await new Promise((resolve, reject) => {
        const outstanding = new Set();
        let stdinEnded = false;
        let finished = false;
        const finish = (error) => {
            if (finished)
                return;
            finished = true;
            void dispose().finally(() => {
                options.onClose?.();
                if (error)
                    reject(error instanceof Error ? error : new Error(String(error)));
                else
                    resolve();
            });
        };
        const finishWhenDrained = () => {
            if (stdinEnded && outstanding.size === 0)
                finish();
        };
        transport.onclose = () => finish();
        transport.onerror = (error) => finish(error);
        server
            .connect(transport)
            .then(() => {
            // `connect` installs the protocol's own handlers, so these wrappers go
            // on afterwards and delegate rather than replace.
            // The stdio transport calls `onmessage` with the message alone — it has
            // no auth or request metadata to carry — so the wrapper takes one
            // parameter and forwards it unchanged.
            const deliver = transport.onmessage;
            transport.onmessage = (message) => {
                const id = requestId(message);
                if ('method' in message && id !== undefined)
                    outstanding.add(id);
                if ('method' in message && message.method === 'notifications/cancelled') {
                    const cancelled = message.params?.requestId;
                    if (typeof cancelled === 'string' || typeof cancelled === 'number')
                        outstanding.delete(cancelled);
                }
                deliver?.(message);
            };
            const send = transport.send.bind(transport);
            transport.send = async (message) => {
                await send(message);
                const id = requestId(message);
                // A response carries an id but no method; a server-initiated request
                // carries both, and must not retire the client request it sits under.
                if (id !== undefined && !('method' in message)) {
                    outstanding.delete(id);
                    finishWhenDrained();
                }
            };
            process.stdin.once('end', () => {
                stdinEnded = true;
                finishWhenDrained();
            });
        })
            .catch(finish);
    });
    log('client disconnected');
}
//# sourceMappingURL=server.js.map