/** * The wire protocol this client speaks. Must match * UEMCP_BRIDGE_PROTOCOL_VERSION in the plugin's MCPHandlerRegistration.h. * * The plugin is compiled by the user, and `attach` is deliberately * non-destructive, so an npm upgrade routinely leaves a new client talking to * an arbitrarily old binary. Comparing these two numbers is how that gets * named instead of surfacing as an unexplained "Unknown method". */ export declare const CLIENT_PROTOCOL_VERSION = 2; /** Answer to get_bridge_capabilities, or what we infer when there is none. */ export interface BridgeCapabilities { protocolVersion: number; handlerApiVersion?: number; /** Compile timestamp of the loaded plugin binary. The stale-DLL tell. */ builtAt?: string; engineVersion?: string; projectName?: string; instanceId?: string; pid?: number; port?: number; startedAt?: string; features?: string[]; actions?: string[]; actionCount?: number; /** True when the bridge did not answer the handshake at all. */ legacy: boolean; } /** * Describe a client/plugin protocol mismatch in terms the reader can act on, * naming both versions. Returns null when the two agree. */ export declare function describeProtocolMismatch(capabilities: BridgeCapabilities | null, method?: string): string | null; /** The record the running bridge publishes for this project. */ export interface BridgeLockfile { port: number; pid?: number; startedAt?: string; /** Identifies the server object that wrote this, across pid recycling. */ instanceId?: string; status?: string; apiVersion?: number; handlerApiVersion?: number; } /** What the bridge leaves behind when the editor started but the bridge did not. */ export interface BridgeErrorRecord { status?: string; pid?: number; failedAt?: string; firstPortTried?: number; lastPortTried?: number; errorCode?: number; detail?: string; } export declare function readBridgeLockfile(uprojectPath: string | null): BridgeLockfile | null; /** * #821: read the record the bridge writes when the editor came up but the * bridge could not bind. Without it, "editor alive, bridge dead" was * indistinguishable from "no editor", and the client said the wrong thing. */ export declare function readBridgeErrorRecord(uprojectPath: string | null): BridgeErrorRecord | null; export interface BridgeResponse { id: string; result?: unknown; error?: { code: number; message: string; }; } /** * A call the client stopped waiting for, and the reply that turned up after * (#799). The editor applies and saves a mutation before it answers, so a * timeout says nothing about whether the change landed. Keeping the record * lets the late reply be reconciled and logged instead of dropped on the floor * as an unrecognised message. */ export interface AbandonedCall { operationId: string; method: string; /** Epoch ms the client gave up waiting. */ abandonedAt: number; /** Epoch ms the editor's reply arrived, if it ever did. */ answeredAt?: number; result?: unknown; error?: string; } /** * How the port the bridge is about to use was chosen. * * The first three are attributable to the targeted project: the lockfile is * written by that project's own editor, the config port comes from that * project's ue-mcp.yml, and the derived port is a hash of that project's root * that the C++ side computes identically. The last two are pins that say * nothing about which project answers on that port. */ export type BridgePortSource = "lockfile" | "config" | "derived" | "explicit" | "env" | "default"; /** Which editor the bridge is pointed at, and how sure it is. */ export interface BridgeTarget { /** Absolute .uproject path whose editor this connection belongs to. */ projectPath: string | null; /** Port the next connect will use, before any lockfile re-read. */ port: number; portSource: BridgePortSource; /** * True when the port is attributable to `projectPath`. False means the port * is a pin inherited from the environment or an earlier target, so * connecting could land on some other project's editor. Connects are * refused in that state (see connect()). */ verified: boolean; } /** Minimal interface for tool handlers - enables mocking in tests. */ export interface IBridge { readonly isConnected: boolean; /** #821: what the connected bridge reported at handshake, when there is one. */ readonly capabilities?: BridgeCapabilities | null; call(method: string, params?: Record, timeoutMs?: number): Promise; connect(timeoutMs?: number): Promise; /** * Move the connection to another project's editor (#818). Drops the current * socket before returning, so the caller can re-point path resolution in the * same synchronous step and never expose a state where the two disagree. */ retargetProject(uprojectPath: string, configPort?: number): BridgeTarget; /** Snapshot of the current target, for reporting and for tests. */ getTarget(): BridgeTarget; } export declare class EditorBridge implements IBridge { private ws; private pending; private abandoned; private reconnectTimer; private connectInFlight; private idCounter; /** * #821: what the bridge said it was, answered on connect. Null before the * first connection. `legacy` marks a plugin old enough not to answer at all. */ capabilities: BridgeCapabilities | null; private portSource; /** * Set once the bridge has been retargeted at a specific project (#818). * A pinned port (constructor arg or UE_MCP_PORT) was chosen for whatever * project the process started on, so after a switch it is not evidence about * the new target. While this is true and no lockfile confirms the port, * connect() refuses rather than risk answering as the wrong editor. */ private unverifiedPin; /** * Bumped on every retarget and every socket teardown. A connect that was * already in flight when the target moved must not install its socket, or a * switch would silently reconnect to the project we just left. */ private targetGeneration; constructor(host?: string, port?: number); host: string; port: number; /** * Apply `bridge.host` from ue-mcp.yml (#817). * * UE_MCP_HOST stays a global default: it is one value for the process, so * with more than one project it points every session at the same machine. * It still wins, which keeps an existing single-project setup exactly as it * was, and this is what one project uses to differ when it is unset. */ setConfigHost(host?: string): void; /** * Apply an explicit `bridge.port` from ue-mcp.yml. Ignored when an * explicit constructor arg or UE_MCP_PORT already pinned the port. */ setConfigPort(port?: number): void; get isConnected(): boolean; ensureConnected(timeoutMs?: number): Promise; /** * #492: project context for resolving the per-project port lockfile. Set * by index.ts (via setProjectContext) after the user's .uproject is loaded. * Leaving this null keeps the default-port-only behaviour for callers that * don't have a project context (CLI tools, tests). */ projectPathForLockfile: string | null; /** * Record the loaded .uproject and, when no port was explicitly pinned, * derive this project's stable per-worktree bridge port from its root path. * The C++ bridge derives the same value; the lockfile reconciles the actual * bound port at connect time either way. */ setProjectContext(uprojectPath: string | null): void; /** * Point the bridge at another project's editor (#818). * * The socket is dropped before anything else, so from the moment this * returns the only editor reachable is the one belonging to `uprojectPath`. * The port is re-decided from that project alone: its lockfile first (the * port its editor actually bound), then its own `bridge.port` config, then * the port derived from its root path. A port pinned by the constructor or * UE_MCP_PORT survives only as a last resort and is flagged unverified, * because it was chosen for a different project. * * Callers must re-point path resolution in the same synchronous step. That * is what keeps the pair honest: a handler can never observe the resolved * project and the connected editor referring to different projects. */ retargetProject(uprojectPath: string, configPort?: number): BridgeTarget; getTarget(): BridgeTarget; connect(timeoutMs?: number): Promise; startReconnecting(intervalMs?: number): void; stopReconnecting(): void; call(method: string, params?: Record, timeoutMs?: number): Promise; disconnect(): void; /** * Drop the socket and fail everything riding on it. Retargeting terminates * instead of closing: a close handshake leaves the socket usable for another * round trip, and the caller is switching projects precisely because nothing * more should reach this editor. */ private closeSocket; /** * Ask the bridge what it is. Deliberately not routed through call(): a slow * or absent answer here must not terminate the socket the way a timed-out * call does, and a bridge old enough to have no answer is a normal outcome * rather than a failure. */ private handshake; /** Record a call the client gave up on, capping the history. */ private rememberAbandoned; /** * Calls this client stopped waiting for, newest last. An entry with * `answeredAt` set is one the editor finished after the timeout, which is * proof the mutation ran (#799). */ get abandonedCalls(): AbandonedCall[]; private setupListeners; }