/** * Bundled pi extension for the ACP MCP bridge (pi-acp-jetbrain). * * The extension receives an immutable, session-scoped catalog over authenticated * IPC, registers each remote tool independently, acknowledges registration, and * forwards calls/results without hiding MCP errors as successful text. */ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent' import { Type, type TSchema } from 'typebox' import { createConnection, type Socket } from 'node:net' import { isAbsolute, resolve } from 'node:path' import { BRIDGE_IPC_VERSION, type BridgeIpcMessage, type BridgeMcpResult, type BridgeTool, type BridgeCatalog, type CatalogRegistration } from '../mcp/types.js' import { IDE_MODE_ENV, FABRIC_MUTATION_CALL_RE, stripJsComments, NATIVE_FILE_TOOLS, MUTATION_REMOTE_NAMES, PATH_KEYS, RESULT_PATH_KEYS, type IdeCodingMode, type IdeCodingState, type IdeCapabilityMap, type MutationPlan, parseIdeCodingMode, indexIdeCapabilities, evaluateIdeAvailability, ideRootMatchesSessionRoot, isInside, tryRealpath, nearestExistingAncestor, normalizeProjectPath, renderIdeCodingGuidance, dedupe, buildMutationPlan, OPTIONAL_CAPABILITIES } from '../policy/ide-coding-policy.js' export * from '../policy/ide-coding-policy.js' const ENDPOINT = process.env.PI_ACP_MCP_IPC_ENDPOINT const TOKEN = process.env.PI_ACP_MCP_IPC_TOKEN const SESSION_ID = process.env.PI_ACP_MCP_SESSION_ID type IpcMessage = BridgeIpcMessage type JsonSchema = Record type ConversionState = { depth: number nodes: number warnings: string[] references: Map resolving: Set } function collectReferences(schema: JsonSchema, state: ConversionState): void { for (const key of ['$defs', 'definitions']) { const definitions = schema[key] if (!definitions || typeof definitions !== 'object' || Array.isArray(definitions)) continue for (const [name, definition] of Object.entries(definitions as JsonSchema)) { if (!definition || typeof definition !== 'object' || Array.isArray(definition)) continue state.references.set(`#/${key}/${name}`, definition as JsonSchema) state.references.set(name, definition as JsonSchema) } } } function createConversionState(): ConversionState { return { depth: 0, nodes: 0, warnings: [], references: new Map(), resolving: new Set() } } const MAX_SCHEMA_DEPTH = 32 const MAX_SCHEMA_NODES = 2000 const MAX_RESULT_DEPTH = 8 const MAX_RESULT_ITEMS = 100 const MAX_RESULT_STRING = 40_000 const MAX_IMAGE_BYTES = 8 * 1024 * 1024 const REDACTED = '[redacted]' function schemaOptions(schema: JsonSchema): Record { const options: Record = {} for (const key of [ 'description', 'title', '$id', 'default', 'format', 'pattern', 'minLength', 'maxLength', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'minItems', 'maxItems', 'uniqueItems', 'minProperties', 'maxProperties', 'examples' ]) { if (schema[key] !== undefined) options[key] = schema[key] } return options } function union(schemas: TSchema[], options?: Record): TSchema { if (schemas.length === 0) return Type.Any() if (schemas.length === 1) return schemas[0] return Type.Union(schemas as [TSchema, TSchema, ...TSchema[]], options as never) } function intersection(schemas: TSchema[], options?: Record): TSchema { if (schemas.length === 0) return Type.Any() if (schemas.length === 1) return schemas[0] return Type.Intersect(schemas as [TSchema, TSchema, ...TSchema[]], options as never) } function literal(value: unknown): TSchema | undefined { if (value === null) return Type.Null() if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { return Type.Literal(value as string | number | boolean) } return undefined } export function schemaToTypeBox(schema: JsonSchema, state: ConversionState = createConversionState()): TSchema { collectReferences(schema, state) state.nodes++ if (state.depth > MAX_SCHEMA_DEPTH || state.nodes > MAX_SCHEMA_NODES) { state.warnings.push('schema widened after depth or node limit') return Type.Any() } const reference = schema.$ref if (typeof reference === 'string') { const target = state.references.get(reference) if (!target) { state.warnings.push(`unresolved JSON Schema reference widened: ${reference}`) return Type.Any(schemaOptions(schema) as never) } if (state.resolving.has(reference)) { state.warnings.push(`cyclic JSON Schema reference widened: ${reference}`) return Type.Any(schemaOptions(schema) as never) } state.resolving.add(reference) try { return schemaToTypeBox(target, state) } finally { state.resolving.delete(reference) } } const options = schemaOptions(schema) const constSchema = schema.const !== undefined ? literal(schema.const) : undefined if (schema.const !== undefined) return constSchema ?? Type.Any(options as never) if (Array.isArray(schema.enum)) { const members = schema.enum.map(value => literal(value)).filter((value): value is TSchema => value !== undefined) if (members.length === schema.enum.length) return union(members, options) state.warnings.push('enum with unsupported literal widened to any') return Type.Any(options as never) } const nested = (child: JsonSchema): TSchema => { state.depth++ try { return schemaToTypeBox(child, state) } finally { state.depth-- } } const combinator = (key: 'anyOf' | 'oneOf' | 'allOf'): TSchema | undefined => { if (!Array.isArray(schema[key])) return undefined const members = schema[key] .filter((item): item is JsonSchema => Boolean(item && typeof item === 'object')) .map(nested) return key === 'allOf' ? intersection(members, options) : union(members, options) } const combined = combinator('allOf') ?? combinator('anyOf') ?? combinator('oneOf') if (combined) return schema.nullable === true ? union([combined, Type.Null()], options) : combined const types = Array.isArray(schema.type) ? schema.type : [schema.type] const named = types.filter((type): type is string => typeof type === 'string') const converted = named.map(type => { switch (type) { case 'string': return Type.String(options as never) case 'number': return Type.Number(options as never) case 'integer': return Type.Integer(options as never) case 'boolean': return Type.Boolean(options as never) case 'null': return Type.Null() case 'array': { const tupleItems = Array.isArray(schema.prefixItems) ? schema.prefixItems : Array.isArray(schema.items) ? schema.items : undefined if (tupleItems) { const items = tupleItems .filter((item): item is JsonSchema => Boolean(item && typeof item === 'object')) .map(nested) return Type.Tuple(items as never, options as never) } const items = schema.items && typeof schema.items === 'object' ? nested(schema.items as JsonSchema) : Type.Any() return Type.Array(items, options as never) } case 'object': return objectSchema(schema, state) case 'any': return Type.Any() default: state.warnings.push(`unsupported JSON Schema type widened: ${type}`) return Type.Any() } }) let result = converted.length > 0 ? union(converted, options) : objectSchema(schema, state) if (schema.nullable === true && !named.includes('null')) result = union([result, Type.Null()], options) return result } function objectSchema(schema: JsonSchema, state: ConversionState): TSchema { const properties = schema.properties && typeof schema.properties === 'object' ? (schema.properties as JsonSchema) : {} const required = new Set( Array.isArray(schema.required) ? schema.required.filter(value => typeof value === 'string') : [] ) const fields: Record = {} for (const [name, child] of Object.entries(properties)) { let converted: TSchema = Type.Any() if (child && typeof child === 'object') { state.depth++ try { converted = schemaToTypeBox(child as JsonSchema, state) } finally { state.depth-- } } fields[name] = required.has(name) ? converted : Type.Optional(converted) } const options = schemaOptions(schema) if (schema.additionalProperties === false) options.additionalProperties = false else if (schema.additionalProperties === true || schema.additionalProperties === undefined) options.additionalProperties = true else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { state.depth++ try { options.additionalProperties = schemaToTypeBox(schema.additionalProperties as JsonSchema, state) } finally { state.depth-- } } return Type.Object(fields, options as never) } function isSensitiveKey(key: string): boolean { return /token|secret|password|authorization|cookie|api[-_]?key|private[-_]?key/i.test(key) } function sanitize(value: unknown, depth = 0): unknown { if (depth > MAX_RESULT_DEPTH) return '[depth limit]' if (typeof value === 'string') return value.length > MAX_RESULT_STRING ? `${value.slice(0, MAX_RESULT_STRING)}…` : value if (value === null || typeof value === 'number' || typeof value === 'boolean') return value if (Array.isArray(value)) return value.slice(0, MAX_RESULT_ITEMS).map(item => sanitize(item, depth + 1)) if (typeof value === 'object') { const output: Record = {} for (const [key, child] of Object.entries(value as Record).slice(0, MAX_RESULT_ITEMS)) { output[key] = isSensitiveKey(key) ? REDACTED : sanitize(child, depth + 1) } return output } return String(value) } function stringify(value: unknown): string { try { return JSON.stringify(value, null, 2) ?? String(value) } catch { return String(value) } } function hasSchemaProperty(schema: JsonSchema, name: string): boolean { const properties = schema.properties return Boolean( properties && typeof properties === 'object' && !Array.isArray(properties) && Object.prototype.hasOwnProperty.call(properties, name) ) } /** Add stable JetBrains IDE project context without mutating the model-provided arguments. */ export function prepareToolArguments( tool: Pick, args: Record, projectPath?: string ): Record { const prepared = { ...args } if (projectPath && prepared.projectPath === undefined && hasSchemaProperty(tool.inputSchema, 'projectPath')) { prepared.projectPath = projectPath } return prepared } function toolDescription(tool: BridgeTool): string { const notes: string[] = [] if (hasSchemaProperty(tool.inputSchema, 'projectPath')) { notes.push('projectPath is supplied from the ACP working directory when omitted.') } if (hasSchemaProperty(tool.inputSchema, 'filePath') || hasSchemaProperty(tool.inputSchema, 'files')) { notes.push('File paths are project-relative unless the tool says otherwise.') } switch (tool.remoteName) { case 'search_symbol': notes.push('Use this before call hierarchy analysis when you need a fully qualified symbol name.') break case 'analyze_calls': notes.push('Pass the exact fully qualified symbol or signature returned by search_symbol.') break case 'get_file_problems': case 'lint_files': notes.push('Prefer JetBrains IDE inspections for IDE-aware diagnostics instead of text-only checks.') break case 'rename_refactoring': notes.push('Use for semantic rename and verify the affected files afterward.') break case 'get_run_configurations': notes.push('Use this first to discover exact run configuration names and executable code locations.') break case 'execute_run_configuration': notes.push('Use an exact configuration name or a run point returned by get_run_configurations.') break case 'xdebug_set_breakpoint': notes.push('Prefer a logpoint with suspendPolicy NONE for low-disturbance runtime evidence.') break case 'xdebug_start_debugger_session': notes.push('Set at least one breakpoint before starting; then wait for a pause.') break case 'xdebug_control_session': notes.push('After RESUME or start, use WAIT_FOR_PAUSE before reading stack/frame state.') break } const base = tool.description ?? `IDE tool ${tool.remoteName} (via ${tool.connectionId})` return notes.length > 0 ? `${base}\n\nPi ACP IDE guidance: ${notes.join(' ')}` : base } export interface PiMcpToolResult { content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> details: Record } export class McpToolError extends Error { readonly details: Record constructor(message: string, details: Record = {}) { super(message) this.name = 'McpToolError' this.details = details } } /** Convert a JSON MCP result to Pi's text/image content plus structured details. */ export function mcpResultToPiResult(value: unknown): PiMcpToolResult { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new McpToolError('MCP tool returned a malformed result', { code: 'malformed_result' }) } const result = value as BridgeMcpResult const safeResult = sanitize(result) as Record const details: Record = { mcp: {} } const content: PiMcpToolResult['content'] = [] if (result.isError === true) { throw new McpToolError(`MCP tool reported an error\n${stringify(safeResult)}`, { code: 'mcp_is_error', result: safeResult }) } if (result.structuredContent !== undefined) details.structuredContent = sanitize(result.structuredContent) if (result._meta !== undefined) details.meta = sanitize(result._meta) const unsupported: unknown[] = [] for (const block of Array.isArray(result.content) ? result.content : []) { if (!block || typeof block !== 'object') { unsupported.push(sanitize(block)) continue } if (block.type === 'text' && typeof block.text === 'string') { content.push({ type: 'text', text: block.text }) } else if ( block.type === 'image' && typeof block.data === 'string' && typeof block.mimeType === 'string' && Buffer.byteLength(block.data, 'base64') <= MAX_IMAGE_BYTES ) { content.push({ type: 'image', data: block.data, mimeType: block.mimeType }) } else if (block.type === 'resource' && block.resource && typeof block.resource === 'object') { const resource = block.resource as Record const uri = typeof resource.uri === 'string' ? resource.uri : 'unknown-uri' if (typeof resource.text === 'string') content.push({ type: 'text', text: `[resource ${uri}]\n${resource.text}` }) else content.push({ type: 'text', text: `[resource ${uri}]` }) unsupported.push({ type: 'resource', resource: sanitize(resource) }) } else if (block.type === 'resource_link' && typeof block.uri === 'string') { content.push({ type: 'text', text: `[resource link ${block.uri}]` }) unsupported.push(sanitize(block)) } else { unsupported.push(sanitize(block)) } } if (unsupported.length > 0) details.unsupportedContent = unsupported.slice(0, MAX_RESULT_ITEMS) const extra = Object.fromEntries( Object.entries(result).filter(([key]) => !['content', 'structuredContent', 'isError', '_meta'].includes(key)) ) if (Object.keys(extra).length > 0) details.mcp = sanitize(extra) as Record if (content.length === 0 && result.structuredContent !== undefined) { content.push({ type: 'text', text: stringify(sanitize(result.structuredContent)) }) } if (content.length === 0) content.push({ type: 'text', text: 'MCP tool returned no textual content.' }) return { content, details } } const BRIDGE_INSTANCE_KEY = Symbol.for('pi-acp-jetbrain.acp-mcp-bridge.instance') export function claimBridgeInstance(scope: object, owner: symbol): boolean { const registry = scope as Record if (registry[BRIDGE_INSTANCE_KEY] !== undefined) return false registry[BRIDGE_INSTANCE_KEY] = owner return true } export function releaseBridgeInstance(scope: object, owner: symbol): void { const registry = scope as Record if (registry[BRIDGE_INSTANCE_KEY] === owner) delete registry[BRIDGE_INSTANCE_KEY] } export interface AcpMcpBridgeRuntime { endpoint?: string token?: string sessionId?: string instanceScope?: object connect?: (endpoint: string) => Socket /** Session working directory override (tests); defaults to process.cwd(). */ sessionCwd?: string /** Base delay for IPC reconnect backoff (tests); defaults to 1000ms. */ reconnectBaseDelayMs?: number } export function createAcpMcpBridgeExtension(runtime: AcpMcpBridgeRuntime = {}): (pi: ExtensionAPI) => void { return pi => activateAcpMcpBridgeExtension(pi, runtime) } export default function acpMcpBridgeExtension(pi: ExtensionAPI): void { activateAcpMcpBridgeExtension(pi) } function activateAcpMcpBridgeExtension(pi: ExtensionAPI, runtime: AcpMcpBridgeRuntime = {}): void { // Register acp-reload command to trigger pi's reload functionality pi.registerCommand('acp-reload', { description: 'Reload pi components (extensions, skills, prompts, themes, context files) from ACP', handler: async (_args, ctx) => { await ctx.reload() } }) let ideMode: IdeCodingMode = 'off' let ideState: IdeCodingState = 'disabled' let capabilities: IdeCapabilityMap = new Map() let projectRoot: string | undefined const policyDiagnostics: string[] = [] const registeredIdeNames = new Set() const registeredToolHashes = new Map() const toolByExposedName = new Map() let removedByPolicy: string[] = [] // pi's loader throws `Extension runtime not initialized` for runtime action // methods while extensions are still loading. IPC frames (and socket close) // can arrive in that window when a nested pi inherits a live adapter's IPC // env; defer the policy transition instead of crashing (F-036). const RUNTIME_NOT_READY_RE = /runtime not initialized|during extension loading/i let deferredPolicyRun: (() => void) | undefined let deferredPolicyAttempts = 0 const MAX_DEFERRED_POLICY_ATTEMPTS = 500 function isRuntimeNotReady(error: unknown): boolean { return error instanceof Error && RUNTIME_NOT_READY_RE.test(error.message) } function schedulePolicyWhenReady(run: () => void): void { deferredPolicyRun = run if (deferredPolicyAttempts >= MAX_DEFERRED_POLICY_ATTEMPTS) { deferredPolicyRun = undefined return } deferredPolicyAttempts += 1 setImmediate(() => { const next = deferredPolicyRun deferredPolicyRun = undefined if (next !== undefined) next() }) } const endpoint = runtime.endpoint ?? ENDPOINT const token = runtime.token ?? TOKEN const sessionId = runtime.sessionId ?? SESSION_ID // The pi child process is spawned with cwd = session cwd by the adapter, so // process.cwd() is the session root the git-based audit normalizes against. const sessionCwd = runtime.sessionCwd ?? process.cwd() const parsedMode = parseIdeCodingMode(process.env[IDE_MODE_ENV]) ideMode = parsedMode.mode if (parsedMode.diagnostic !== undefined) policyDiagnostics.push(parsedMode.diagnostic) if (ideMode !== 'off') { pi.on('before_agent_start', ((event: { systemPrompt: string }) => { const guidance = renderIdeCodingGuidance(ideMode, ideState, capabilities, projectRoot) // Consume diagnostics: they describe transitions since the previous // turn, and the current state remains visible through the guidance // header. Rendering them every turn would let a session with frequent // reconnect cycles grow the systemPrompt without bound. const diagnostics = policyDiagnostics.join('\n') policyDiagnostics.length = 0 const text = diagnostics === '' ? guidance : guidance === '' ? diagnostics : `${guidance}\n${diagnostics}` if (text === '') return undefined return { systemPrompt: `${event.systemPrompt}\n\n${text}` } }) as never) if (ideMode === 'required') setPolicyFiltering(true) if (!endpoint || !token || !sessionId) { if (ideMode === 'prefer') ideState = 'native_fallback' else ideState = 'required_unavailable' return } ideState = 'awaiting_catalog' } else if (!endpoint || !token || !sessionId) { return } const instanceScope = runtime.instanceScope ?? globalThis const owner = Symbol('acp-mcp-bridge-owner') if (!claimBridgeInstance(instanceScope, owner)) return let sock: Socket | undefined let buffer = '' let registered = false const pending = new Map void; reject: (e: Error) => void }>() // IPC reconnect: a lost bridge no longer strands the session in a degraded // state until a new chat. Retry with capped exponential backoff; a healthy // registration resets the attempt counter. Skipped after session shutdown. const RECONNECT_MAX_DELAY_MS = 15_000 const reconnectBaseDelayMs = Math.max(0, runtime.reconnectBaseDelayMs ?? 1_000) let reconnectAttempts = 0 let reconnectTimer: ReturnType | undefined let shuttingDown = false function scheduleReconnect(reason: string): void { if (shuttingDown || reconnectTimer !== undefined) return const delay = Math.min(reconnectBaseDelayMs * 2 ** reconnectAttempts, RECONNECT_MAX_DELAY_MS) reconnectAttempts += 1 policyDiagnostics.push( `IDE bridge IPC lost (${reason}); reconnect attempt ${reconnectAttempts} in ${Math.round(delay)}ms` ) reconnectTimer = setTimeout(() => { reconnectTimer = undefined try { connect() } catch (error) { scheduleReconnect(`connect failed: ${error instanceof Error ? error.message : String(error)}`) } }, delay) reconnectTimer.unref?.() } function send(msg: IpcMessage): void { if (!sock || sock.destroyed) return try { sock.write(JSON.stringify(msg) + '\n') } catch { // Socket close handling surfaces pending failures. } } function registerTools(tools: BridgeTool[]): CatalogRegistration { const registration: CatalogRegistration = { catalogId: undefined, registered: [], failed: [] } const names = new Set() for (const tool of tools) { try { if (tool.schemaHash !== undefined && registeredToolHashes.get(tool.exposedName) === tool.schemaHash) { // Reconnect with an unchanged catalog: the tool is already live in // pi with a closure bound to this extension's mutable socket state, // so re-registering would only risk duplicate-name errors. Report // it as registered so the adapter-side catalog validation still // sees full coverage. registration.registered.push({ exposedName: tool.exposedName, schemaHash: tool.schemaHash }) names.add(tool.exposedName) continue } if (names.has(tool.exposedName)) { registration.failed.push({ exposedName: tool.exposedName, schemaHash: tool.schemaHash, message: 'duplicate exposed tool name' }) continue } names.add(tool.exposedName) const conversionState = createConversionState() const schema = schemaToTypeBox(tool.inputSchema, conversionState) pi.registerTool({ name: tool.exposedName, label: tool.exposedName.replaceAll('_', ' '), description: toolDescription(tool), parameters: schema as never, execute: (async (toolCallId: string, params: unknown, signal?: AbortSignal) => runtimeExecute(tool, toolCallId, params, signal)) as never }) registration.registered.push({ exposedName: tool.exposedName, schemaHash: tool.schemaHash }) registeredIdeNames.add(tool.exposedName) if (tool.schemaHash !== undefined) registeredToolHashes.set(tool.exposedName, tool.schemaHash) toolByExposedName.set(tool.exposedName, tool) if (conversionState.warnings.length > 0) { registration.diagnostics = [ ...(registration.diagnostics ?? []), `${tool.exposedName}: ${conversionState.warnings.join('; ')}` ] } } catch (error) { registration.failed.push({ exposedName: tool.exposedName, schemaHash: tool.schemaHash, message: error instanceof Error ? error.message : String(error) }) } } return registration } function handleMessage(msg: IpcMessage): void { if (msg.type === 'hello_ack' && !registered) { if (typeof pi.getActiveTools === 'function') { try { pi.getActiveTools() } catch (error) { if (!isRuntimeNotReady(error)) throw error schedulePolicyWhenReady(() => handleMessage(msg)) return } } registered = true reconnectAttempts = 0 projectRoot = msg.catalog.projectPath || undefined const registration = registerTools(msg.catalog.tools) registration.catalogId = msg.catalog.catalogId applyIdePolicy(msg.catalog, registration) send({ type: 'catalog_registered', registration }) send({ type: 'health', health: { state: registration.failed.length === 0 ? 'registration_complete' : 'registration_partial', catalogId: msg.catalog.catalogId, diagnostics: [ ...(registration.diagnostics ?? []), ...registration.failed.map(item => `${item.exposedName}: ${item.message}`) ] } }) return } if (msg.type === 'result') { const call = pending.get(msg.id) if (call) { pending.delete(msg.id) call.resolve(msg.result as PiMcpToolResult) } return } if (msg.type === 'error') { const call = pending.get(msg.id) if (call) { pending.delete(msg.id) call.reject(new Error(msg.message)) } } } function connect(): void { if (sock) return sock = (runtime.connect ?? createConnection)(endpoint!) sock.setEncoding('utf8') sock.on('connect', () => { send({ type: 'hello', version: BRIDGE_IPC_VERSION, token: token!, sessionId: sessionId! }) }) sock.on('data', (chunk: Buffer) => { buffer += chunk.toString('utf8') let idx: number while ((idx = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, idx).trim() buffer = buffer.slice(idx + 1) if (!line) continue let msg: IpcMessage try { msg = JSON.parse(line) as IpcMessage } catch { continue } handleMessage(msg) } }) sock.on('close', () => { sock = undefined buffer = '' registered = false const error = new Error('IDE bridge IPC disconnected; IDE tools unavailable') for (const [, call] of pending) call.reject(error) pending.clear() if (ideMode === 'prefer') transitionIdeState('native_fallback', 'IPC disconnected') else if (ideMode === 'required') transitionIdeState('required_unavailable', 'IPC disconnected') scheduleReconnect('socket closed') }) sock.on('error', () => sock?.destroy()) } function callRemoteTool( tool: BridgeTool, args: Record, requestId: string, signal?: AbortSignal ): Promise { if (!sock || sock.destroyed || !registered) { return Promise.reject(new Error('IDE bridge unavailable: IPC is disconnected')) } return new Promise((resolve, reject) => { const onAbort = () => { if (!pending.delete(requestId)) return send({ type: 'cancel', id: requestId }) failed(new Error('IDE tool call cancelled')) } const done = (value: unknown) => { try { let mapped = mcpResultToPiResult(value) if (ideMode !== 'off') { const rawStructured = value && typeof value === 'object' && !Array.isArray(value) ? (value as BridgeMcpResult).structuredContent : undefined mapped = filterIdeResult(tool, mapped, ideMode, projectRoot, rawStructured) } signal?.removeEventListener('abort', onAbort) resolve(mapped) } catch (error) { signal?.removeEventListener('abort', onAbort) reject(error instanceof Error ? error : new Error(String(error))) } } const failed = (error: Error) => { signal?.removeEventListener('abort', onAbort) reject(error) } pending.set(requestId, { resolve: done, reject: failed }) if (signal?.aborted) { pending.delete(requestId) failed(new Error('IDE tool call cancelled')) return } signal?.addEventListener('abort', onAbort, { once: true }) let ok = true try { if (!sock || sock.destroyed) ok = false else sock.write(JSON.stringify({ type: 'call', id: requestId, tool: tool.exposedName, args }) + '\n') } catch { ok = false } if (!ok) { pending.delete(requestId) signal?.removeEventListener('abort', onAbort) failed(new Error('IDE bridge unavailable: IPC is disconnected')) } }) } function filterIdeResult( tool: BridgeTool, result: PiMcpToolResult, mode: IdeCodingMode, root: string | undefined, rawStructured?: unknown ): PiMcpToolResult { if (mode === 'off' || MUTATION_REMOTE_NAMES.has(tool.remoteName) || root === undefined) return result let hit = false const structured = rawStructured ?? result.details.structuredContent if (structured && typeof structured === 'object' && !Array.isArray(structured)) { const candidates: string[] = [] const budget = { nodes: 5000 } let truncated = false const collect = (value: unknown, depth: number): void => { if (depth > 16 || budget.nodes <= 0) { truncated = true return } budget.nodes-- if (typeof value === 'string') { candidates.push(value) return } if (Array.isArray(value)) { for (const item of value) collect(item, depth + 1) return } if (value && typeof value === 'object') { for (const [key, child] of Object.entries(value as Record)) { if (RESULT_PATH_KEYS.has(key)) { collect(child, depth + 1) } else if (child && typeof child === 'object') { collect(child, depth + 1) } } } } collect(structured, 0) if (truncated) { if (mode === 'required') { throw new McpToolError('IDE tool result exceeded confinement depth; rejected as unscoped', { code: 'out_of_root_result' }) } result.details.composite = { ...(result.details.composite as Record), unconfinedResult: 'truncated' } } const rootResolved = resolve(root) const realRoot = tryRealpath(rootResolved) for (const entry of candidates) { if (entry === '') continue const candidate = isAbsolute(entry) ? resolve(entry) : resolve(rootResolved, entry) if (!isInside(rootResolved, candidate)) { hit = true break } if (realRoot !== undefined) { const existingAncestor = nearestExistingAncestor(candidate) if (existingAncestor !== undefined) { const real = tryRealpath(existingAncestor) if (real !== undefined && !isInside(realRoot, real)) { hit = true break } } } } } if (!hit) return result if (mode === 'required') { throw new McpToolError('IDE tool returned a path outside the project root', { code: 'out_of_root_result' }) } result.details.composite = { ...(result.details.composite as Record), outOfRootResult: 'path outside project root (annotated)' } return result } function setPolicyFiltering(enabled: boolean): void { if (ideMode === 'off') return try { applyPolicyFiltering(enabled) deferredPolicyAttempts = 0 } catch (error) { if (!isRuntimeNotReady(error)) throw error schedulePolicyWhenReady(() => setPolicyFiltering(enabled)) } } function applyPolicyFiltering(enabled: boolean): void { if (ideMode === 'off') return const current = pi.getActiveTools() if (enabled) { const removed = current.filter(name => NATIVE_FILE_TOOLS.has(name)) if (removed.length === 0) return removedByPolicy = [...new Set([...removedByPolicy, ...removed])] pi.setActiveTools(current.filter(name => !NATIVE_FILE_TOOLS.has(name))) } else { if (removedByPolicy.length === 0) return const known = new Set(pi.getAllTools().map(tool => tool.name)) const restore = removedByPolicy.filter(name => known.has(name) && !current.includes(name)) if (restore.length === 0) return pi.setActiveTools([...current, ...restore]) removedByPolicy = removedByPolicy.filter(name => !restore.includes(name)) } } function activateIdeTools(enabled: boolean): void { if (ideMode === 'off' || registeredIdeNames.size === 0) return try { applyIdeToolActivation(enabled) deferredPolicyAttempts = 0 } catch (error) { if (!isRuntimeNotReady(error)) throw error schedulePolicyWhenReady(() => activateIdeTools(enabled)) } } function applyIdeToolActivation(enabled: boolean): void { if (ideMode === 'off' || registeredIdeNames.size === 0) return const current = pi.getActiveTools() if (enabled) { const missing = [...registeredIdeNames].filter(name => !current.includes(name)) if (missing.length > 0) pi.setActiveTools([...current, ...missing]) } else { const next = current.filter(name => !registeredIdeNames.has(name)) if (next.length !== current.length) pi.setActiveTools(next) } } function transitionIdeState(next: IdeCodingState, reason: string): void { if (ideState === next) return const prev = ideState ideState = next // Mirror the state to the adapter so its provenance gate can tell an // active IDE (native mutation tools removed) apart from a fallback state // (native edit/write sanctioned again). Send is a no-op while the IPC is // down; the adapter clears its copy on client close, and the next // transition after reconnect re-publishes the state. send({ type: 'ide_state', state: next }) policyDiagnostics.push(`IDE coding mode: ${prev} -> ${next} (${reason})`) switch (next) { case 'active': setPolicyFiltering(true) activateIdeTools(true) break case 'native_fallback': activateIdeTools(false) setPolicyFiltering(false) break case 'required_unavailable': activateIdeTools(false) break case 'shutdown': activateIdeTools(false) break default: break } } function applyIdePolicy(catalog: BridgeCatalog, registration: CatalogRegistration): void { if (ideMode === 'off') return if (catalog.projectPath === undefined || catalog.projectPath === '') { failIdePolicy(registration, 'no IDE project root was reported') return } if (!ideRootMatchesSessionRoot(catalog.projectPath, sessionCwd)) { failIdePolicy( registration, `IDE project root ${catalog.projectPath} does not match the session cwd (${sessionCwd})` ) return } const registeredNames = new Set(registration.registered.map(item => item.exposedName)) const indexed = indexIdeCapabilities(catalog.tools, registeredNames) capabilities = indexed.capabilities for (const duplicate of indexed.duplicates) policyDiagnostics.push(`duplicate IDE capability mappings: ${duplicate}`) const availability = evaluateIdeAvailability(ideMode, indexed.capabilities) // The transition line states the outcome itself: a bare phase name left the // reader unable to tell whether the evaluation passed, and the missing list // was previously emitted again as a second, near-identical line. const optionalMissing = OPTIONAL_CAPABILITIES.filter(group => !capabilities.has(group.key)).map(g => g.key) let outcome: string if (availability.missing.length > 0) { outcome = `missing required IDE capabilities: ${availability.missing.join(', ')}` } else if (optionalMissing.length === 0) { outcome = 'all required IDE capabilities present' } else { outcome = `all required IDE capabilities present; optional unavailable: ${optionalMissing.join(', ')}` } transitionIdeState(availability.state, outcome) if (policyDiagnostics.length > 0) { registration.diagnostics = [...(registration.diagnostics ?? []), ...policyDiagnostics] policyDiagnostics.length = 0 } } /** Fail closed to the mode's degraded state; the transition line carries the reason. */ function failIdePolicy(registration: CatalogRegistration, reason: string): void { const fallback = ideMode === 'prefer' ? 'native_fallback' : 'required_unavailable' transitionIdeState(fallback, reason) if (policyDiagnostics.length > 0) { registration.diagnostics = [...(registration.diagnostics ?? []), ...policyDiagnostics] policyDiagnostics.length = 0 } } function openPathKey(tool: BridgeTool): string { const schema = tool.inputSchema as { properties?: Record } const properties = schema.properties ?? {} for (const key of ['filePath', 'file_path', 'files', 'pathInProject', 'directoryPath']) { if (Object.prototype.hasOwnProperty.call(properties, key)) return key } return 'filePath' } function executeMutationComposite( tool: BridgeTool, args: Record, toolCallId: string, signal?: AbortSignal, onApplied?: (paths: string[]) => void ): Promise { if (projectRoot === undefined) { return Promise.reject( new McpToolError('IntelliJ-first mode requires a project root', { code: 'missing_project_root' }) ) } const openName = capabilities.get('open') const openTool = openName ? toolByExposedName.get(openName) : undefined if (!openTool) { return Promise.reject( new McpToolError('IntelliJ-first mode requires open_file_in_editor for mutations', { code: 'missing_open_capability' }) ) } const confined = confineToolArgs(tool, args) let plan: MutationPlan try { plan = buildMutationPlan(tool, confined, projectRoot) } catch (error) { return Promise.reject(error instanceof Error ? error : new Error(String(error))) } const openKey = openPathKey(openTool) return (async () => { for (let i = 0; i < plan.preOpen.length; i++) { await callRemoteTool( openTool, prepareToolArguments(openTool, { [openKey]: plan.preOpen[i] }, projectRoot), `${toolCallId}:open:${i}`, signal ) } const result = await callRemoteTool(tool, plan.mutationArgs, `${toolCallId}:mutate`, signal) if (onApplied) onApplied(dedupe([...plan.preOpen, ...plan.postOpen])) for (let i = 0; i < plan.postOpen.length; i++) { try { await callRemoteTool( openTool, prepareToolArguments(openTool, { [openKey]: plan.postOpen[i] }, projectRoot), `${toolCallId}:open-created:${i}`, signal ) } catch (error) { result.details.composite = { ...(result.details.composite as Record), mutationSucceeded: true, postOpenError: error instanceof Error ? error.message : String(error) } } } result.details.composite = { ...(result.details.composite as Record), affectedPaths: [...plan.preOpen, ...plan.postOpen] } return result })() } function confineToolArgs(tool: BridgeTool, args: Record): Record { if (ideMode === 'off' || projectRoot === undefined) return args const root = projectRoot const schema = tool.inputSchema as { properties?: Record } const properties = schema.properties ?? {} const next = { ...args } for (const key of Object.keys(properties)) { if (!PATH_KEYS.has(key)) continue const value = args[key] if (typeof value === 'string') { next[key] = normalizeProjectPath(root, value, true).path } else if (Array.isArray(value)) { next[key] = value.map(item => (typeof item === 'string' ? normalizeProjectPath(root, item, true).path : item)) } } return next } function runtimeExecute( tool: BridgeTool, toolCallId: string, params: unknown, signal?: AbortSignal ): Promise { const args = (params ?? {}) as Record const prepared = prepareToolArguments(tool, args, projectRoot) if (ideMode === 'off') return callRemoteTool(tool, prepared, toolCallId, signal) if (MUTATION_REMOTE_NAMES.has(tool.remoteName)) { return executeMutationComposite(tool, prepared, toolCallId, signal, applied => { send({ type: 'mutations_applied', paths: applied }) }) } return callRemoteTool(tool, confineToolArgs(tool, prepared), toolCallId, signal) } // IntelliJ-first enforcement: block direct Fabric/Schema file mutations while // an active IDE coding mode is in effect so writes flow through the IDE tools // (ide_idea_apply_patch / create_new_file). The active-set policy already // filters native file tools; fabric_exec is the remaining bypass vector. // The gate is a heuristic text scan over comment-stripped code: it reliably // catches direct and whitespace-obfuscated calls, but aliased or indirect // invocations can still bypass it (documented; not a security boundary). if (ideMode !== 'off') { pi.on('tool_call', async event => { if (ideState === 'disabled' || ideState === 'native_fallback' || ideState === 'shutdown') return undefined if (event?.toolName !== 'fabric_exec') return undefined const code = typeof event?.input?.code === 'string' ? event.input.code : '' if (!FABRIC_MUTATION_CALL_RE.test(stripJsComments(code))) return undefined return { block: true, reason: 'IntelliJ-first mode: direct Fabric/Schema file mutations are blocked; apply and validate changes with ide_idea_apply_patch or ide_idea_create_new_file via extensions.ide_*' } }) } pi.on('session_shutdown', () => { shuttingDown = true if (reconnectTimer !== undefined) { clearTimeout(reconnectTimer) reconnectTimer = undefined } transitionIdeState('shutdown', 'session shutdown') send({ type: 'shutdown', reason: 'session_shutdown' }) sock?.destroy() sock = undefined releaseBridgeInstance(instanceScope, owner) }) try { connect() } catch (error) { releaseBridgeInstance(instanceScope, owner) throw error } } // ---------- IntelliJ-first coding mode ----------