opencode-idea 0.0.16 → 0.0.18

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.
@@ -0,0 +1,170 @@
1
+ // Arguments this plugin OWNS on the IDE's own MCP tools.
2
+ //
3
+ // Three files, three different subjects — kept apart on purpose:
4
+ //
5
+ // * `idea.js` the IDE's bundled MCP server: endpoint, transport, probe
6
+ // * `idea-tool-defaults` (this file) that server's TOOL CATALOG, and the
7
+ // arguments on it that this plugin controls
8
+ // * `enhance.js` a companion plugin's tools, which may not be installed
9
+ //
10
+ // ## Why an argument can be owned at all
11
+ //
12
+ // `<prefix>build_project`(每个项目前缀各一份,例如 `ide_build_project`)是模型判断
13
+ // 刚写的代码能否编译的唯一手段,而它的结论依赖模型看不到的文件新鲜度:
14
+ // an INCREMENTAL build compares against the IDE's own view of the files and
15
+ // recompiles only what that view calls dirty. When the edit happened OUTSIDE the
16
+ // IDE, the view can still be clean and the build becomes a no-op that reports
17
+ // success — a false "it compiles", the worst possible answer for a model
18
+ // verifying its own edit.
19
+ //
20
+ // MEASURED (IntelliJ 2026.2.3, decompiled `AnalysisToolset` plus 6 live
21
+ // scenarios): `build_project` maps to
22
+ // `ProjectTaskManager.createAllModulesBuildTask(project, isIncrementalBuild = !rebuild)`,
23
+ // so `rebuild: true` is the switch that makes the answer independent of VFS
24
+ // freshness. All six scenarios (edit an existing file / add a new one, in any
25
+ // package, open in the editor or not) were detected with `rebuild: true`, and
26
+ // only intermittently without it.
27
+ //
28
+ // ## Two halves of the same decision
29
+ //
30
+ // An argument that is always overwritten must not also be advertised. Leaving it
31
+ // in the schema would let the model set it, reason about it, and be wrong, while
32
+ // the value it wrote is silently discarded. So [`FORCED_ARGUMENTS`] drives both
33
+ // halves from one table:
34
+ //
35
+ // * [hideForcedArguments] drops it from the published input schema
36
+ // * [applyIdeaToolDefaults] sets the value on every call
37
+ //
38
+ // ## On rewriting a tool's schema
39
+ //
40
+ // This is a departure from the earlier "never touch tool definitions" rule, and it
41
+ // is deliberately narrow: nothing here INVENTS or EXPLAINS. The IDE's own
42
+ // `build_project` description does not mention the argument at all — only its
43
+ // per-property description does — so removing the property leaves a schema that
44
+ // is still internally consistent and still entirely the IDE's. What a schema
45
+ // transform must never do is add advice; that belongs to the guidance text.
46
+
47
+ /**
48
+ * Arguments this plugin sets on every call, and therefore must not advertise.
49
+ *
50
+ * 工具用**后缀**标识,不写完整名字:同一个 IDE 工具会以每个项目的前缀各出现一次
51
+ * (`ide_build_project` / `modelserver_build_project` / ...),写死 `idea_...` 会让
52
+ * 多项目场景里只有 root 被命中,子项目那几份原样漏过去 —— 那正是这个模块要避免的
53
+ * "schema 说一套、调用做另一套"。
54
+ *
55
+ * @type {ReadonlyArray<{ suffix: string, argument: string, value: unknown }>}
56
+ */
57
+ export const FORCED_ARGUMENTS = [
58
+ {
59
+ suffix: 'build_project',
60
+ argument: 'rebuild',
61
+ value: true,
62
+ },
63
+ ];
64
+
65
+ /** 由前缀集合拼出这张表涉及的**全部**真实工具名。 */
66
+ export function forcedToolNames(prefixes) {
67
+ return prefixes.flatMap((prefix) => FORCED_ARGUMENTS.map((owned) => `${prefix}${owned.suffix}`));
68
+ }
69
+
70
+ /**
71
+ * Set every owned argument on one call.
72
+ *
73
+ * Mutates `input` in place and reports whether anything changed, so a caller can
74
+ * keep "the model asked for this" apart from "we forced it". Unknown tools and
75
+ * malformed drafts are left untouched — this function owns the tools it names and
76
+ * nothing else.
77
+ *
78
+ * Called from `execute.before`, which hands out a mutable draft. The runtime
79
+ * validates a tool call against the schema published for THAT request and only
80
+ * then runs the hook, so setting a property the schema no longer advertises is
81
+ * safe.
82
+ *
83
+ * @param {unknown} tool Effective tool name for this call (`event.tool`).
84
+ * @param {unknown} input Mutable argument draft for this call (`event.input`).
85
+ * @param {readonly string[]} prefixes 本会话所有项目的工具前缀(`ide_` 等)。
86
+ * @returns {boolean} Whether `input` was modified.
87
+ */
88
+ export function applyIdeToolDefaults(tool, input, prefixes) {
89
+ if (typeof tool !== 'string') return false;
90
+ if (input === null || typeof input !== 'object') return false;
91
+
92
+ let changed = false;
93
+ for (const owned of FORCED_ARGUMENTS) {
94
+ const matches = prefixes.some((prefix) => tool === `${prefix}${owned.suffix}`);
95
+ if (!matches) continue;
96
+ if (input[owned.argument] === owned.value) continue;
97
+ input[owned.argument] = owned.value;
98
+ changed = true;
99
+ }
100
+ return changed;
101
+ }
102
+
103
+ /**
104
+ * Drop one property from a JSON schema, returning a new object.
105
+ *
106
+ * Returns the input unchanged (by reference) when there is nothing to remove, so
107
+ * callers can skip an otherwise pointless edit. Everything else — including
108
+ * `additionalProperties` — is copied through untouched: this function only takes
109
+ * something away, and must never become a place where schema semantics change.
110
+ *
111
+ * @param {unknown} schema
112
+ * @param {string} name
113
+ * @returns {unknown} The new schema, or `schema` itself when unchanged.
114
+ */
115
+ export function removeSchemaProperty(schema, name) {
116
+ if (schema === null || typeof schema !== 'object') return schema;
117
+ const properties = schema.properties;
118
+ if (properties === null || typeof properties !== 'object') return schema;
119
+ if (!(name in properties)) return schema;
120
+
121
+ const nextProperties = { ...properties };
122
+ delete nextProperties[name];
123
+
124
+ const next = { ...schema, properties: nextProperties };
125
+ if (Array.isArray(schema.required)) {
126
+ const required = schema.required.filter((entry) => entry !== name);
127
+ // Drop an emptied `required` rather than leaving `[]` behind: the two mean
128
+ // the same thing on the wire, but a stale empty array reads like intent.
129
+ if (required.length > 0) next.required = required;
130
+ else delete next.required;
131
+ }
132
+ return next;
133
+ }
134
+
135
+ /**
136
+ * Remove every owned argument from the tools this plugin controls.
137
+ *
138
+ * Takes the `ctx.tool.transform` editor (`list` / `get` / `update`). Written
139
+ * against that surface rather than against OpenCode's types so it stays a pure
140
+ * function of what it is given — which is also what makes it testable without a
141
+ * runtime.
142
+ *
143
+ * 每个项目前缀各处理一遍:同一个 IDE 工具会在工具表里以多份出现,漏掉任何一份都会
144
+ * 让那一份的 schema 继续广告这个旋钮。
145
+ *
146
+ * Safe to run when the IDE is absent: `get` returns undefined for a tool that is
147
+ * not registered, and OpenCode replays the transform after every MCP catalog
148
+ * refresh, so the tools are adjusted once they appear.
149
+ *
150
+ * @param {{
151
+ * get: (id: string) => { input?: unknown } | undefined,
152
+ * update: (id: string, update: (tool: { input?: unknown }) => void) => void,
153
+ * }} editor
154
+ * @param {readonly string[]} prefixes
155
+ */
156
+ export function hideForcedArguments(editor, prefixes) {
157
+ for (const name of forcedToolNames(prefixes)) {
158
+ const owned = FORCED_ARGUMENTS.find((entry) => name.endsWith(entry.suffix));
159
+ if (!owned) continue;
160
+ const tool = editor.get(name);
161
+ if (!tool) continue;
162
+ const next = removeSchemaProperty(tool.input, owned.argument);
163
+ if (next === tool.input) continue;
164
+ // Assign the whole schema: the editor documents that schemas are replaced,
165
+ // not mutated in place.
166
+ editor.update(name, (draft) => {
167
+ draft.input = next;
168
+ });
169
+ }
170
+ }
package/src/mcp/idea.js CHANGED
@@ -37,29 +37,23 @@ export function ideProjectPath(projectPath) {
37
37
  return projectPath.replace(/\\/g, '/');
38
38
  }
39
39
 
40
- /** OpenCode MCP server name registered for the IDE. */
41
- export const IDEA_SERVER_NAME = 'idea';
42
-
43
40
  /** Streamable-HTTP endpoint exposed by the IDE MCP server (IntelliJ 2026.2+). */
44
41
  export const MCP_STREAM_PATH = '/stream';
45
42
 
46
- /** Port the IDE MCP server listens on when not configured explicitly. */
47
- export const DEFAULT_PORTS = [64342];
48
-
49
43
  const PROTOCOL_VERSION = '2025-06-18';
50
- // A probe is only a local availability check. Keep it short: a disabled IDE
51
- // MCP endpoint must not make the user wait for the launch timeout before the
52
- // actionable notification is sent.
53
- const PROBE_TIMEOUT_MS = 1000;
54
44
  const CALL_TIMEOUT_MS = 15000;
55
45
 
56
46
  /**
57
47
  * Build the OpenCode MCP server config for a given IDE port + project.
58
48
  *
59
49
  * `codemode: false` exposes the IDE tools directly on the provider tool list
60
- * (as `idea_<tool>`) instead of routing them through Code Mode. Direct exposure
61
- * passes each tool's full schema to the model, so parameter names are known
62
- * without exploration. The rest of an MCP server's options are unchanged.
50
+ * (as `<server>_<tool>`) instead of routing them through Code Mode. Direct
51
+ * exposure passes each tool's full schema to the model, so parameter names are
52
+ * known without exploration. The rest of an MCP server's options are unchanged.
53
+ *
54
+ * The server NAME is not decided here: it is the project's tool prefix, chosen
55
+ * by the caller (root -> `ide`, a subproject -> its directory name). See
56
+ * `ide-projects.js` for why the prefix encodes the LEVEL, not the IDE product.
63
57
  *
64
58
  * @param {number} port
65
59
  * @param {string} projectPath
@@ -95,66 +89,6 @@ function withExecutionTimeout(config, options) {
95
89
  }
96
90
 
97
91
 
98
- /**
99
- * Is an IDE MCP server listening on `port`? One Streamable-HTTP `initialize`
100
- * POST decides it: any 2xx JSON reply means the server is up.
101
- *
102
- * @param {number} port
103
- * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [options]
104
- * @returns {Promise<boolean>}
105
- */
106
- export async function probePort(port, options = {}) {
107
- const fetchImpl = options.fetchImpl ?? fetch;
108
- const controller = new AbortController();
109
- const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? PROBE_TIMEOUT_MS);
110
- try {
111
- const response = await fetchImpl(`http://127.0.0.1:${port}${MCP_STREAM_PATH}`, {
112
- method: 'POST',
113
- headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' },
114
- // Do not follow redirects. IntelliJ can keep its normal HTTP port open
115
- // while MCP is disabled and redirect `/stream` to an HTML 404 page;
116
- // following that redirect only adds latency and still means unavailable.
117
- redirect: 'manual',
118
- body: JSON.stringify({
119
- jsonrpc: '2.0',
120
- id: 1,
121
- method: 'initialize',
122
- params: {
123
- protocolVersion: PROTOCOL_VERSION,
124
- capabilities: {},
125
- clientInfo: { name: 'opencode-idea', version: '0.0.16' },
126
- },
127
- }),
128
- signal: controller.signal,
129
- });
130
- return response.ok;
131
- } catch {
132
- return false;
133
- } finally {
134
- clearTimeout(timer);
135
- controller.abort();
136
- }
137
- }
138
-
139
- /**
140
- * Find the first port whose IDE MCP server responds. Returns the port, or
141
- * `undefined` when none is available.
142
- *
143
- * @param {number[]} ports
144
- * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [options]
145
- * @returns {Promise<number | undefined>}
146
- */
147
- export async function findIdePort(ports, options = {}) {
148
- // Probe all candidate ports concurrently. These are fallback locations for
149
- // one IDE MCP endpoint, not several active MCP servers. Sequential probes
150
- // multiply the timeout by the number of candidates and made a disabled MCP
151
- // look like a hang.
152
- const results = await Promise.all(
153
- ports.map(async (port) => [port, await probePort(port, options)]),
154
- );
155
- return results.find(([, available]) => available)?.[0];
156
- }
157
-
158
92
  /**
159
93
  * Call one MCP tool over the Streamable-HTTP endpoint. Returns the raw tool
160
94
  * result (with `isError`), or `undefined` on transport/connect failure.
@@ -193,7 +127,7 @@ export async function callTool(port, projectPath, name, args = {}, options = {})
193
127
  params: {
194
128
  protocolVersion: PROTOCOL_VERSION,
195
129
  capabilities: {},
196
- clientInfo: { name: 'opencode-idea', version: '0.0.16' },
130
+ clientInfo: { name: 'opencode-idea', version: '0.0.18' },
197
131
  },
198
132
  });
199
133
  // The IDE's Streamable-HTTP endpoint binds follow-up requests to the session