opencode-idea 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.5
4
+
5
+ - **冷启动等待 MCP 端点**:`/open-in-idea` 在 `open -a` 之后按 `mcpStartTimeoutMs`(默认 60s)轮询,直到 IDEA 自带的 MCP server 开始监听。冷启动时一次命令即可连上,不再像之前那样探测一次失败就报「未检测到 MCP 服务」。
6
+ - **工具就绪判定**:连接后轮询 `ctx.tool.list()`,确认 `idea_read_file` / `idea_apply_patch` 已注册。未就绪时状态为 `tools-loading` 并提示稍后重试,不再在原生工具表尚未就绪时谎称「已连接」。
7
+ - **重复执行刷新工具列表**:再次执行 `/open-in-idea` 会重建 MCP server 并依次 `ctx.mcp.reload()` + `ctx.tool.reload()`,让已经捕获过工具快照的会话立即看到最新工具,而不是停留在旧快照。
8
+ - **IDE 掉线自动注销**:每次请求前复检端点,IDEA 关闭或某次 `idea_*` 工具调用失败时,立即移除 `idea_*` 工具并恢复原生工具,避免后续请求继续调用已失效的 IDE 工具;只有再次执行 `/open-in-idea` 才能重新注册。
9
+ - **清理**:移除调试日志;删除过时的 `scripts/publish.sh`(旧包名 + bypass token 流程,已由 `scripts/release.mjs` 取代);`package.json` 的 `files` 移除早已删除的 `src/idea-tool-descriptions.js`。
10
+
11
+ ## 0.0.4
12
+
13
+ - **`/open-in-idea` 刷新工具列表**:在 `ctx.mcp.reload()` 之后显式调用 `ctx.tool.reload()`。之前只有 MCP 层重建目录,已经捕获过工具快照的会话仍然看不到新的 `idea_*` 原生工具;现在下一次模型请求即可用,无需重开会话。
14
+
3
15
  ## 0.0.3
4
16
 
5
17
  - **直接暴露 IDEA MCP 工具**:注册 MCP 服务器时设置 `codemode: false`,让 `idea_*` 工具直接出现在原生工具表(而非 Code Mode 的 `tools.idea.*`)。模型可以直接调用并看到完整参数 schema,不再需要先探索、也不会猜错参数名。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-idea",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "OpenCode plugin that connects to JetBrains IDE MCP servers and prefers IDE tools when the current project is open in the IDE",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -14,7 +14,6 @@
14
14
  "src/env.js",
15
15
  "src/ide-launcher.js",
16
16
  "src/ide-guidance.js",
17
- "src/idea-tool-descriptions.js",
18
17
  "README.md",
19
18
  "CHANGELOG.md"
20
19
  ],
package/src/idea-mcp.js CHANGED
@@ -81,7 +81,7 @@ export async function probePort(port, options = {}) {
81
81
  params: {
82
82
  protocolVersion: PROTOCOL_VERSION,
83
83
  capabilities: {},
84
- clientInfo: { name: 'opencode-idea', version: '0.0.3' },
84
+ clientInfo: { name: 'opencode-idea', version: '0.0.5' },
85
85
  },
86
86
  }),
87
87
  signal: controller.signal,
@@ -151,7 +151,7 @@ export async function callTool(port, projectPath, name, args = {}, options = {})
151
151
  params: {
152
152
  protocolVersion: PROTOCOL_VERSION,
153
153
  capabilities: {},
154
- clientInfo: { name: 'opencode-idea', version: '0.0.3' },
154
+ clientInfo: { name: 'opencode-idea', version: '0.0.5' },
155
155
  },
156
156
  });
157
157
  const response = await post({
package/src/plugin.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  DEFAULT_PORTS,
24
24
  IDEA_SERVER_NAME,
25
25
  findIdePort,
26
+ probePort,
26
27
  serverConfig,
27
28
  } from './idea-mcp.js';
28
29
  import { readIdeTerminalEnv } from './ide-env.js';
@@ -43,10 +44,38 @@ const DEFAULT_LAUNCH_MAX_ATTEMPTS = 5;
43
44
  // MCP availability is a fast preflight. If it is not ready, fail fast and let
44
45
  // the user enable MCP + Brave Mode in IDEA before retrying the command.
45
46
  const DEFAULT_MCP_PROBE_TIMEOUT_MS = 1000;
47
+ // A cold IDE can take tens of seconds before its bundled MCP server listens.
48
+ // After `open -a`, poll for the endpoint instead of reporting failure once.
49
+ const DEFAULT_MCP_START_TIMEOUT_MS = 60000;
50
+ const MCP_START_POLL_MS = 1000;
51
+ const DEFAULT_TOOL_REFRESH_TIMEOUT_MS = 5000;
52
+ const TOOL_REFRESH_POLL_MS = 25;
53
+ const IDEA_READY_TOOL_IDS = new Set(['idea_read_file', 'idea_apply_patch']);
54
+
55
+ /**
56
+ * Remove IDEA MCP definitions from one request's tool record.
57
+ *
58
+ * @param {Record<string, unknown> | undefined} tools
59
+ */
60
+ function removeIdeaToolDefinitions(tools) {
61
+ if (!tools || typeof tools !== 'object') return 0;
62
+ let removed = 0;
63
+ for (const name of Object.keys(tools)) {
64
+ if (!name.startsWith('idea_')) continue;
65
+ delete tools[name];
66
+ removed += 1;
67
+ }
68
+ return removed;
69
+ }
70
+
71
+ /** @param {ReadonlyArray<{ id?: string }> | undefined} tools */
72
+ function toolIds(tools) {
73
+ return (tools ?? []).map((tool) => tool.id).filter((id) => typeof id === 'string').sort();
74
+ }
46
75
 
47
76
  /**
48
77
  * @typedef {{
49
- * status: 'connected' | 'mcp-unavailable' | 'launch-failed' | 'disabled',
78
+ * status: 'connected' | 'tools-loading' | 'mcp-unavailable' | 'launch-failed' | 'disabled',
50
79
  * port?: number,
51
80
  * opened?: boolean,
52
81
  * unavailableNoticeSent?: boolean
@@ -71,10 +100,12 @@ const asPositiveNumber = (value, fallback) =>
71
100
  export function ideFeedback(result) {
72
101
  switch (result?.status) {
73
102
  case 'connected':
74
- return 'IDE MCP 已连接;IDE MCP 与项目环境已即时加载。';
103
+ return 'IDE MCP 已连接;IDEA 原生工具已注册并可用于后续请求。';
104
+ case 'tools-loading':
105
+ return 'IDE MCP 已连接,但原生 IDEA 工具列表尚未完成注册。请稍后再次执行 /open-in-idea。';
75
106
  case 'mcp-unavailable':
76
107
  return result?.opened
77
- ? '已请求打开/激活 IntelliJ IDEA,但未检测到 MCP 服务。请在 IDEA 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-idea。'
108
+ ? '已请求打开/激活 IntelliJ IDEA 并等待 MCP 服务启动,但未检测到 MCP 服务。请在 IDEA 中开启 MCP 服务(Settings → MCP Server,需 2026.2+)并启用 Brave Mode,然后再次执行 /open-in-idea。'
78
109
  : '未检测到 IntelliJ IDEA 的 MCP 服务。请确认 IDEA 已打开,在 Settings → MCP Server 开启 MCP 服务并启用 Brave Mode,然后再次执行 /open-in-idea。';
79
110
  case 'launch-failed':
80
111
  return '无法拉起 IntelliJ IDEA(未找到应用或启动失败)。请确认已安装 IDEA,或手动打开后重试 /open-in-idea。';
@@ -112,6 +143,8 @@ export default {
112
143
  * launchCooldownMs?: number,
113
144
  * launchMaxAttempts?: number,
114
145
  * mcpProbeTimeoutMs?: number,
146
+ * mcpStartTimeoutMs?: number,
147
+ * toolRefreshTimeoutMs?: number,
115
148
  * feedback?: false | 'message',
116
149
  * },
117
150
  * command?: {
@@ -123,13 +156,21 @@ export default {
123
156
  * },
124
157
  * session?: {
125
158
  * prompt: (input: { sessionID: string, text: string, delivery: unknown }) => Promise<unknown>,
126
- * hook: (name: string, cb: (event: { system?: unknown[] }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
159
+ * hook: (name: string, cb: (event: {
160
+ * sessionID?: string,
161
+ * agent?: string,
162
+ * system?: unknown[],
163
+ * tools?: Record<string, unknown>,
164
+ * }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
127
165
  * },
128
- * tool: {
129
- * transform: (cb: (editor: {
130
- * list: () => ReadonlyArray<{ id?: string }>,
131
- * update: (id: string, update: (tool: { description?: string }) => void) => void,
132
- * }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
166
+ * tool?: {
167
+ * list?: () => Promise<ReadonlyArray<{ id?: string }>>,
168
+ * reload?: () => Promise<void>,
169
+ * hook?: (name: string, cb: (event: {
170
+ * tool?: string,
171
+ * status?: string,
172
+ * error?: { message?: string },
173
+ * }) => void | Promise<void>) => Promise<{ dispose: () => Promise<void> | void }>,
133
174
  * },
134
175
  * mcp: {
135
176
  * transform: (cb: (editor: { set: (name: string, config: unknown) => void }) => void) => Promise<{ dispose: () => Promise<void> | void }>,
@@ -148,6 +189,8 @@ export default {
148
189
  const injectGuidance = options.injectGuidance !== false;
149
190
  const ideApp = resolveIdeApp(options.openInIde);
150
191
  const mcpProbeTimeoutMs = asPositiveNumber(options.mcpProbeTimeoutMs, DEFAULT_MCP_PROBE_TIMEOUT_MS);
192
+ const mcpStartTimeoutMs = asPositiveNumber(options.mcpStartTimeoutMs, DEFAULT_MCP_START_TIMEOUT_MS);
193
+ const toolRefreshTimeoutMs = asPositiveNumber(options.toolRefreshTimeoutMs, DEFAULT_TOOL_REFRESH_TIMEOUT_MS);
151
194
  // "message" (default) injects a clearly-labelled notification that the
152
195
  // model only acknowledges ("收到"); the user sees the clean notice via
153
196
  // metadata.displayText. false is silent.
@@ -156,9 +199,9 @@ export default {
156
199
  cooldownMs: asPositiveNumber(options.launchCooldownMs, DEFAULT_LAUNCH_COOLDOWN_MS),
157
200
  maxAttempts: asPositiveNumber(options.launchMaxAttempts, DEFAULT_LAUNCH_MAX_ATTEMPTS),
158
201
  });
159
-
160
202
  /** @type {number | undefined} */
161
203
  let activePort;
204
+ let activeToolsReady = false;
162
205
  let currentEnv = { env: {}, prependPath: [] };
163
206
 
164
207
  /** @type {Array<{ dispose: () => Promise<void> | void }>} */
@@ -169,10 +212,13 @@ export default {
169
212
  let sessionRegistration;
170
213
  /** @type {{ dispose: () => Promise<void> | void } | undefined} */
171
214
  let commandRegistration;
215
+ /** @type {{ dispose: () => Promise<void> | void } | undefined} */
216
+ let toolRegistration;
172
217
  let disposed = false;
173
218
 
174
219
  const deactivateIde = async () => {
175
220
  activePort = undefined;
221
+ activeToolsReady = false;
176
222
  const current = registrations.slice();
177
223
  registrations.length = 0;
178
224
  if (current.length === 0) return;
@@ -185,17 +231,73 @@ export default {
185
231
  }
186
232
  };
187
233
 
234
+ const waitForIdeaTools = async () => {
235
+ if (typeof ctx.tool?.list !== 'function') return false;
236
+ const deadline = Date.now() + toolRefreshTimeoutMs;
237
+ while (Date.now() < deadline) {
238
+ try {
239
+ const ids = toolIds(await ctx.tool.list());
240
+ if ([...IDEA_READY_TOOL_IDS].every((id) => ids.includes(id))) return true;
241
+ } catch {
242
+ // The MCP catalog may still be settling; retry within the bounded wait.
243
+ }
244
+ await new Promise((resolve) => setTimeout(resolve, TOOL_REFRESH_POLL_MS));
245
+ }
246
+ return false;
247
+ };
248
+
249
+ const refreshIdeTools = async () => {
250
+ activeToolsReady = false;
251
+ if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
252
+ // Reconcile the MCP servers above, then replay the tool registry so the
253
+ // refreshed catalog lands in the next model request's tool snapshot.
254
+ // Without this, a session that already captured a snapshot keeps the old
255
+ // tool list until the session is reopened.
256
+ if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
257
+ activeToolsReady = await waitForIdeaTools();
258
+ return activeToolsReady;
259
+ };
260
+
188
261
  const activateIde = async (port) => {
189
262
  const mcpRegistration = await ctx.mcp.transform((editor) => {
190
263
  editor.set(IDEA_SERVER_NAME, serverConfig(port, projectPath));
191
264
  });
192
265
  registrations = [mcpRegistration];
193
266
  activePort = port;
194
- if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
267
+ return refreshIdeTools();
268
+ };
269
+
270
+ /** @type {Promise<boolean> | undefined} */
271
+ let healthCheck;
272
+ /**
273
+ * A manually activated IDE can disappear while the OpenCode background
274
+ * service keeps running (the desktop client and the service have separate
275
+ * lifetimes). Re-check the endpoint before every request that could expose
276
+ * IDEA tools, and unregister them as soon as the endpoint is gone. Only
277
+ * `/open-in-idea` may activate them again.
278
+ *
279
+ * @returns {Promise<boolean>}
280
+ */
281
+ const verifyActiveIde = async () => {
282
+ if (activePort === undefined) return true;
283
+ if (healthCheck) return healthCheck;
284
+ const port = activePort;
285
+ healthCheck = (async () => {
286
+ const available = await probePort(port, { timeoutMs: mcpProbeTimeoutMs });
287
+ if (available) return true;
288
+ if (activePort !== port) return true;
289
+ await deactivateIde();
290
+ if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
291
+ if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
292
+ return false;
293
+ })().finally(() => {
294
+ healthCheck = undefined;
295
+ });
296
+ return healthCheck;
195
297
  };
196
298
 
197
299
  const reconcileOnce = async ({ probeIde = true } = {}) => {
198
- if (disposed) return;
300
+ if (disposed) return false;
199
301
 
200
302
  // Manual: only `/open-in-idea` probes the IDE. Setup calls this with
201
303
  // `probeIde: false` so it never connects until the command asks.
@@ -204,7 +306,7 @@ export default {
204
306
  if (!isJetBrains) {
205
307
  currentEnv = { env: {}, prependPath: [] };
206
308
  await deactivateIde();
207
- return;
309
+ return false;
208
310
  }
209
311
 
210
312
  // Environment: read from the IDE integrated terminal, the single source
@@ -218,9 +320,13 @@ export default {
218
320
  shellRegistration = await ctx.shell.hook('create.before', (input) => applyEnv(input, currentEnv));
219
321
  }
220
322
 
221
- if (port === activePort) return;
323
+ // A repeated /open-in-idea must still refresh the current server. The
324
+ // previous implementation returned here, leaving the session on its old
325
+ // tool snapshot even though the command appeared successful.
326
+ if (port !== undefined && port === activePort) return refreshIdeTools();
222
327
  await deactivateIde();
223
- if (port !== undefined) await activateIde(port);
328
+ if (port === undefined) return false;
329
+ return activateIde(port);
224
330
  };
225
331
 
226
332
  // Serialize reconciles so overlapping callers can never register/activate
@@ -235,6 +341,25 @@ export default {
235
341
  return next;
236
342
  };
237
343
 
344
+ /**
345
+ * `open -a` only requests activation/opening. A cold IDE needs time before
346
+ * its bundled MCP server listens, so poll until the endpoint appears. This
347
+ * is the step that makes a single `/open-in-idea` enough on a cold start.
348
+ *
349
+ * @returns {Promise<number | undefined>}
350
+ */
351
+ const waitForIdeServer = async () => {
352
+ const deadline = Date.now() + mcpStartTimeoutMs;
353
+ while (Date.now() < deadline) {
354
+ const port = await findIdePort(ports, { timeoutMs: mcpProbeTimeoutMs }).catch(() => undefined);
355
+ if (port !== undefined) return port;
356
+ const remaining = deadline - Date.now();
357
+ if (remaining <= 0) break;
358
+ await new Promise((resolve) => setTimeout(resolve, Math.min(MCP_START_POLL_MS, remaining)));
359
+ }
360
+ return undefined;
361
+ };
362
+
238
363
  // Re-entrant: repeat invocations share one open request so the IDE is not
239
364
  // spawned twice while the application is starting.
240
365
  /** @type {Promise<IdeCommandResult> | undefined} */
@@ -275,9 +400,15 @@ export default {
275
400
  guard.markSpawn();
276
401
  const launched = await openInIde(projectPath, { app: ideApp });
277
402
  if (!launched) return { status: 'launch-failed' };
278
- // `open -a` only requests activation/opening. Fast-fail if the MCP
279
- // endpoint is not already available; the user can enable it in IDEA
280
- // and run `/open-in-idea` again instead of waiting on a timer.
403
+ // `open -a` only requests activation/opening. A cold IDE needs time to
404
+ // start its bundled MCP server, so wait for the endpoint instead of
405
+ // reporting failure immediately.
406
+ const started = await waitForIdeServer();
407
+ if (started !== undefined) {
408
+ guard.reset();
409
+ launchNoticeSent = false;
410
+ return { status: 'connected', port: started, unavailableNoticeSent: false };
411
+ }
281
412
  await reportUnavailable(true);
282
413
  return { status: 'mcp-unavailable', opened: true, unavailableNoticeSent: launchNoticeSent };
283
414
  })();
@@ -326,36 +457,59 @@ export default {
326
457
  return unavailableNoticeSent;
327
458
  },
328
459
  });
329
- const { port, status, unavailableNoticeSent: resultNoticeSent } = result;
460
+ const { port } = result;
461
+ let toolsReady = false;
330
462
  try {
331
463
  // Do not repeat the expensive probe when the preflight already
332
464
  // found no MCP endpoint. The command will retry after the user
333
465
  // enables MCP + Brave Mode.
334
- await reconcile({ probeIde: port !== undefined });
466
+ toolsReady = (await reconcile({ probeIde: port !== undefined })) === true;
335
467
  } catch {
336
468
  // capability load is best effort; feedback still matters
337
469
  }
470
+ const finalResult = result.status === 'connected' && !toolsReady
471
+ ? { ...result, status: 'tools-loading' }
472
+ : result;
338
473
  // The unavailable notice is the final result for this command. If
339
474
  // it was already delivered, do not show the same warning twice.
340
- const unavailableAlreadySent = unavailableNoticeSent || resultNoticeSent;
341
- if (!unavailableAlreadySent || status !== 'mcp-unavailable') {
342
- await sendFeedback(input, result);
475
+ const unavailableAlreadySent = unavailableNoticeSent || result.unavailableNoticeSent;
476
+ if (!unavailableAlreadySent || finalResult.status !== 'mcp-unavailable') {
477
+ await sendFeedback(input, finalResult);
343
478
  }
344
479
  },
345
480
  });
346
481
  });
347
482
  }
348
483
 
349
- // Direct-call guidance: while the IDEA MCP is available, append concise
350
- // routing rules to every primary request. Native tools are left untouched.
351
- if (injectGuidance && typeof ctx.session?.hook === 'function') {
352
- sessionRegistration = await ctx.session.hook('context', (event) => {
484
+ // Every model request re-checks a manually activated IDE. If the endpoint
485
+ // is gone, drop IDEA tools from this request and from the registry so only
486
+ // a new `/open-in-idea` can bring them back. Guidance is appended only for
487
+ // an IDE that is still alive.
488
+ if (typeof ctx.session?.hook === 'function') {
489
+ sessionRegistration = await ctx.session.hook('context', async (event) => {
490
+ const ideActive = activePort !== undefined && activeToolsReady;
491
+ const available = ideActive ? await verifyActiveIde() : false;
492
+ const tools = event?.tools;
493
+ if (!available) removeIdeaToolDefinitions(tools);
353
494
  appendIdeRecoveryGuidance(event?.system);
354
- if (activePort === undefined) return;
495
+ if (!injectGuidance) return;
496
+ if (!available) return;
355
497
  appendIdeGuidance(event?.system, projectPath);
356
498
  });
357
499
  }
358
500
 
501
+ // A failed IDEA MCP call is also a signal that the IDE endpoint is gone.
502
+ // Unregister immediately so later requests cannot keep calling a dead tool.
503
+ if (typeof ctx.tool?.hook === 'function') {
504
+ toolRegistration = await ctx.tool.hook('execute.after', async (event) => {
505
+ if (typeof event?.tool !== 'string' || !event.tool.startsWith('idea_')) return;
506
+ if (event.status !== 'error') return;
507
+ await deactivateIde();
508
+ if (typeof ctx.mcp.reload === 'function') await ctx.mcp.reload();
509
+ if (typeof ctx.tool?.reload === 'function') await ctx.tool.reload();
510
+ });
511
+ }
512
+
359
513
  return async () => {
360
514
  disposed = true;
361
515
  if (shellRegistration) {
@@ -379,6 +533,13 @@ export default {
379
533
  // best effort
380
534
  }
381
535
  }
536
+ if (toolRegistration) {
537
+ try {
538
+ await toolRegistration.dispose();
539
+ } catch {
540
+ // best effort
541
+ }
542
+ }
382
543
  await deactivateIde();
383
544
  };
384
545
  },