opencode-idea 0.0.17 → 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.
- package/README.md +227 -159
- package/package.json +5 -1
- package/skills/idea-standalone/SKILL.md +4 -2
- package/skills/idea-standalone/assets/AGENTS.template.md +5 -5
- package/src/ide-guidance.js +109 -39
- package/src/ide-guidance.template.md +86 -39
- package/src/ide-launcher.js +87 -27
- package/src/ide-projects.js +262 -0
- package/src/mcp/enhance.js +36 -24
- package/src/mcp/idea-tool-defaults.js +27 -12
- package/src/mcp/idea.js +8 -74
- package/src/plugin.js +715 -355
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// IDE 项目集合:一个 OpenCode 会话(root 项目)可以接入多个 JetBrains 项目 ——
|
|
2
|
+
// root 本身,加上 root 下任意层级的子目录;每个项目可以被**不同的** IDE 打开
|
|
3
|
+
// (root 用 IDEA、modelserver 用 PyCharm 是常态)。
|
|
4
|
+
//
|
|
5
|
+
// 这个模块只做纯数据:持久化标记的解析/序列化、工具前缀(= MCP server 名)的派生、
|
|
6
|
+
// 以及新增子项目前的校验。所有 I/O、探测、注册都留在 plugin.js,所以这里全部是
|
|
7
|
+
// 可以直接测的纯函数。
|
|
8
|
+
//
|
|
9
|
+
// ## 命名规则
|
|
10
|
+
//
|
|
11
|
+
// 工具前缀就是 MCP server 名,由项目的**层级**决定,与 IDE 产品无关:
|
|
12
|
+
//
|
|
13
|
+
// * root → `ide` root 用 IDEA 还是 PyCharm,它都叫 `ide_*`
|
|
14
|
+
// * 子项目 → 目录名 `modelserver` → `modelserver_*`
|
|
15
|
+
//
|
|
16
|
+
// 为什么不叫 `idea_*`:那会把"root 项目"和"IntelliJ IDEA 这个产品"绑死,而
|
|
17
|
+
// 「root 用 PyCharm 打开、子项目用 IDEA 打开」是完全合理的组合。
|
|
18
|
+
//
|
|
19
|
+
// ## 为什么前缀必须严格控制字符集
|
|
20
|
+
//
|
|
21
|
+
// 前缀会变成工具名的一部分(`modelserver_search_symbol`),模型要照着调。目录名
|
|
22
|
+
// 里但凡有空格、点、大写或非 ASCII,工具名就会变得难写、难赌 —— 跨平台还可能
|
|
23
|
+
// 产生"同名不同目录"。所以不规范的目录名**一律拒绝**(见 validateSubproject),
|
|
24
|
+
// 由用户改目录名,插件绝不自动改名或消歧。
|
|
25
|
+
|
|
26
|
+
import { basename, sep } from 'node:path';
|
|
27
|
+
|
|
28
|
+
/** root 项目的 MCP server 名。与 IDE 产品解耦,见文件头。 */
|
|
29
|
+
export const ROOT_SERVER_NAME = 'ide';
|
|
30
|
+
|
|
31
|
+
/** `ctx.options.ides` 允许的取值;未知值会被忽略。 */
|
|
32
|
+
export const SUPPORTED_IDES = ['idea', 'pycharm'];
|
|
33
|
+
|
|
34
|
+
/** 每个 IDE 选项值对应的显示名(提示词里写给模型看)。 */
|
|
35
|
+
export const IDE_LABELS = {
|
|
36
|
+
idea: 'IntelliJ IDEA',
|
|
37
|
+
pycharm: 'PyCharm',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 每个 IDE 的 MCP 端口。
|
|
42
|
+
*
|
|
43
|
+
* **固定值,不开放配置**:JetBrains 给内置 MCP server 分配的就是这个端口,跨版本、
|
|
44
|
+
* 跨项目都不变(IDEA 的 MCP 在 64342,它的内置服务是 63342;PyCharm 的 MCP 在 64462,
|
|
45
|
+
* 内置服务是 63343)。既然"哪个 IDE 在哪个端口"是产品常量,插件就不需要"扫描候选端口
|
|
46
|
+
* 找归属":探哪个端口由项目的 IDE 唯一决定,不经过任何用户配置。
|
|
47
|
+
*
|
|
48
|
+
* 导出成**可变对象**而不是冻结字面量:测试要把它们指到假端口上(见
|
|
49
|
+
* `tests/plugin.test.js` 的 `fakeCtx`),运行时没有任何人写它。
|
|
50
|
+
*/
|
|
51
|
+
export const IDE_PORTS = {
|
|
52
|
+
idea: 64342,
|
|
53
|
+
pycharm: 64462,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 子项目目录名(= 工具前缀)必须匹配的形状。
|
|
58
|
+
*
|
|
59
|
+
* 小写字母或数字开头,其后只允许小写字母、数字、`-`、`_`。上限 48 是硬约束之一
|
|
60
|
+
* 的保守取值:工具名要在模型提示里反复出现,越短越好。
|
|
61
|
+
*/
|
|
62
|
+
export const SUBPROJECT_PREFIX = /^[a-z0-9][a-z0-9_-]{0,47}$/;
|
|
63
|
+
|
|
64
|
+
/** 持久化标记里存的版本号,用于将来识别旧结构。 */
|
|
65
|
+
export const MARK_VERSION = 2;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {{ path: string, app: string }} IdeProject
|
|
69
|
+
* @typedef {{ root: IdeProject, children: IdeProject[] }} IdeConfig
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/** 这个 root 的持久化标记 key。 */
|
|
73
|
+
export function markKeyFor(rootPath) {
|
|
74
|
+
return `project/${rootPath}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {unknown} entry @returns {boolean} */
|
|
78
|
+
function isProjectEntry(entry) {
|
|
79
|
+
return (
|
|
80
|
+
entry !== null &&
|
|
81
|
+
typeof entry === 'object' &&
|
|
82
|
+
typeof (/** @type {IdeProject} */ (entry).path) === 'string' &&
|
|
83
|
+
typeof (/** @type {IdeProject} */ (entry).app) === 'string'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 解析持久化的标记值。
|
|
89
|
+
*
|
|
90
|
+
* 兼容 v1:旧版本存的是布尔 `true`(只表示"root 已接入",没有 IDE 与子项目信息),
|
|
91
|
+
* 这里把它读成"root 用 idea 的 v1 配置"。任何无法识别的形状都当作**未配置**
|
|
92
|
+
* (`undefined`),因为一个读不懂的配置比没有配置更危险。
|
|
93
|
+
*
|
|
94
|
+
* @param {unknown} raw 从 `ctx.storage` 读到的原始值
|
|
95
|
+
* @param {string} rootPath
|
|
96
|
+
* @returns {IdeConfig | undefined}
|
|
97
|
+
*/
|
|
98
|
+
export function parseMark(raw, rootPath) {
|
|
99
|
+
if (raw === true) return { root: { path: rootPath, app: 'idea' }, children: [] };
|
|
100
|
+
if (raw === null || typeof raw !== 'object') return undefined;
|
|
101
|
+
const record = /** @type {{ root?: unknown, children?: unknown }} */ (raw);
|
|
102
|
+
if (!isProjectEntry(record.root)) return undefined;
|
|
103
|
+
const root = /** @type {IdeProject} */ (record.root);
|
|
104
|
+
const children = Array.isArray(record.children)
|
|
105
|
+
? record.children.filter(isProjectEntry).map((entry) => {
|
|
106
|
+
const child = /** @type {IdeProject} */ (entry);
|
|
107
|
+
return { path: child.path, app: child.app };
|
|
108
|
+
})
|
|
109
|
+
: [];
|
|
110
|
+
return { root: { path: root.path, app: root.app }, children };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {IdeConfig} config @returns {Record<string, unknown>} */
|
|
114
|
+
export function serializeMark(config) {
|
|
115
|
+
return {
|
|
116
|
+
version: MARK_VERSION,
|
|
117
|
+
root: { path: config.root.path, app: config.root.app },
|
|
118
|
+
children: config.children.map((child) => ({ path: child.path, app: child.app })),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 一个项目对应的 MCP server 名(= 工具前缀)。
|
|
124
|
+
*
|
|
125
|
+
* @param {string} path
|
|
126
|
+
* @param {string} rootPath
|
|
127
|
+
*/
|
|
128
|
+
export function serverNameFor(path, rootPath) {
|
|
129
|
+
return path === rootPath ? ROOT_SERVER_NAME : basename(path);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 展开成"项目 + 前缀"的平铺列表,顺序固定:root 在最前,子项目按登记顺序。
|
|
134
|
+
*
|
|
135
|
+
* @param {IdeConfig} config
|
|
136
|
+
* @returns {Array<{ path: string, app: string, server: string, isRoot: boolean }>}
|
|
137
|
+
*/
|
|
138
|
+
export function listProjects(config) {
|
|
139
|
+
const root = {
|
|
140
|
+
path: config.root.path,
|
|
141
|
+
app: config.root.app,
|
|
142
|
+
server: ROOT_SERVER_NAME,
|
|
143
|
+
isRoot: true,
|
|
144
|
+
};
|
|
145
|
+
const children = config.children.map((child) => ({
|
|
146
|
+
path: child.path,
|
|
147
|
+
app: child.app,
|
|
148
|
+
server: serverNameFor(child.path, config.root.path),
|
|
149
|
+
isRoot: false,
|
|
150
|
+
}));
|
|
151
|
+
return [root, ...children];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 当前配置里所有项目的工具前缀(带下划线),例如 `["ide_", "modelserver_"]`。 */
|
|
155
|
+
export function toolPrefixes(config) {
|
|
156
|
+
return listProjects(config).map((project) => `${project.server}_`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 校验:把 `absPath` 登记为 root 的子项目是否合法。
|
|
161
|
+
*
|
|
162
|
+
* 全部规则都是**拒绝**,不做任何自动修正 —— 与插件其它地方一致:静默改一个名字,
|
|
163
|
+
* 等于把用户的目录和模型看到的工具名对不上,而且没人会发现。
|
|
164
|
+
*
|
|
165
|
+
* @param {IdeConfig} config
|
|
166
|
+
* @param {string} absPath 待登记的绝对路径
|
|
167
|
+
* @returns {{ ok: true } | { ok: false, code: string, message: string }}
|
|
168
|
+
*/
|
|
169
|
+
export function validateSubproject(config, absPath) {
|
|
170
|
+
const rootPath = config.root.path;
|
|
171
|
+
const prefix = rootPath.endsWith(sep) ? rootPath : `${rootPath}${sep}`;
|
|
172
|
+
if (!absPath.startsWith(prefix)) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
code: 'outside-root',
|
|
176
|
+
message: `必须位于 root 项目(${rootPath})之下`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const name = basename(absPath);
|
|
180
|
+
if (!SUBPROJECT_PREFIX.test(name)) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
code: 'invalid-name',
|
|
184
|
+
message: `目录名 \`${name}\` 不能作为工具前缀:要求 ^[a-z0-9][a-z0-9_-]{0,47}$,即以小写字母或数字开头,只含小写字母、数字、- 和 _`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (name === ROOT_SERVER_NAME) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
code: 'reserved',
|
|
191
|
+
message: `\`${ROOT_SERVER_NAME}\` 是 root 项目保留的工具前缀,请在别处使用这个目录名`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const existing = config.children.find((child) => child.path === absPath);
|
|
195
|
+
if (existing) {
|
|
196
|
+
return { ok: false, code: 'already-registered', message: `\`${name}\` 已经登记过了` };
|
|
197
|
+
}
|
|
198
|
+
const clash = config.children.find((child) => basename(child.path) === name);
|
|
199
|
+
if (clash) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
code: 'duplicate-prefix',
|
|
203
|
+
message: `工具前缀 \`${name}\` 已被 ${clash.path} 占用;两个子项目不能同名,请重命名其中一个目录`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return { ok: true };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 加入一个子项目,返回新配置(不改入参)。
|
|
211
|
+
*
|
|
212
|
+
* @param {IdeConfig} config
|
|
213
|
+
* @param {string} absPath
|
|
214
|
+
* @param {string} app
|
|
215
|
+
* @returns {IdeConfig}
|
|
216
|
+
*/
|
|
217
|
+
export function withSubproject(config, absPath, app) {
|
|
218
|
+
return {
|
|
219
|
+
root: { ...config.root },
|
|
220
|
+
children: [...config.children.map((child) => ({ ...child })), { path: absPath, app }],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 从参数文本里切出子项目路径。
|
|
226
|
+
*
|
|
227
|
+
* 斜杠命令的参数是**整串**(`input.prompt.text`),不分词,所以这里自己切:
|
|
228
|
+
* 取第一个空白分隔的片段。多给的部分是错的,宁可报错也不要猜。
|
|
229
|
+
*
|
|
230
|
+
* @param {unknown} text
|
|
231
|
+
* @returns {{ ok: true, path: string } | { ok: false, message: string }}
|
|
232
|
+
*/
|
|
233
|
+
export function parseSubprojectArgument(text) {
|
|
234
|
+
const raw = typeof text === 'string' ? text.trim() : '';
|
|
235
|
+
if (raw === '') return { ok: false, message: '缺少子项目路径参数' };
|
|
236
|
+
const parts = raw.split(/\s+/);
|
|
237
|
+
if (parts.length > 1) {
|
|
238
|
+
return {
|
|
239
|
+
ok: false,
|
|
240
|
+
message: `只接受一个参数(子项目路径),收到 ${parts.length} 个:\`${raw}\``,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return { ok: true, path: parts[0] };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 从参数文本里切出 IDE 名。
|
|
248
|
+
*
|
|
249
|
+
* `/open-in-idea pycharm` 是"AI 不配合时的直通逃生口":正常流程由 AI 向用户提问后
|
|
250
|
+
* 调工具写回,带参只是保险。空串表示"没给",由调用方决定是提问还是用默认值。
|
|
251
|
+
*
|
|
252
|
+
* @param {unknown} text
|
|
253
|
+
* @returns {{ ok: true, app: string | undefined } | { ok: false, message: string }}
|
|
254
|
+
*/
|
|
255
|
+
export function parseIdeArgument(text) {
|
|
256
|
+
const raw = typeof text === 'string' ? text.trim().toLowerCase() : '';
|
|
257
|
+
if (raw === '') return { ok: true, app: undefined };
|
|
258
|
+
if (!SUPPORTED_IDES.includes(raw)) {
|
|
259
|
+
return { ok: false, message: `不支持的 IDE \`${raw}\`,可选:${SUPPORTED_IDES.join(' / ')}` };
|
|
260
|
+
}
|
|
261
|
+
return { ok: true, app: raw };
|
|
262
|
+
}
|
package/src/mcp/enhance.js
CHANGED
|
@@ -18,42 +18,54 @@
|
|
|
18
18
|
// has to be gated on the tools really being in the request (`servingRunLifecycleTools`).
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* The enhance plugin's tool names as
|
|
21
|
+
* The enhance plugin's tool names, as **suffixes**.
|
|
22
22
|
*
|
|
23
|
-
* OpenCode namespaces an MCP server's tools with the server name, and
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `
|
|
23
|
+
* OpenCode namespaces an MCP server's tools with the server name, and each
|
|
24
|
+
* project is registered under its own prefix (root = `ide`, a subproject = its
|
|
25
|
+
* directory name). So the wire name is `<prefix><suffix>` — for example
|
|
26
|
+
* `ide_rerun_run_configuration` or `modelserver_rerun_run_configuration`.
|
|
27
|
+
*
|
|
28
|
+
* 存后缀而不是完整名字:同一个工具在每个项目前缀下各有一份,写死任何一个前缀都会
|
|
29
|
+
* 让别的项目查不到它。
|
|
27
30
|
*
|
|
28
31
|
* Keep in sync with `RunLifecycleSchemas.kt` in the enhance repository — the
|
|
29
|
-
*
|
|
30
|
-
* provided this tool": MCP carries no such marker.
|
|
32
|
+
* suffixes are its contract, and there is no runtime way to discover "which
|
|
33
|
+
* plugin provided this tool": MCP carries no such marker.
|
|
31
34
|
*/
|
|
32
|
-
export const
|
|
33
|
-
export const
|
|
34
|
-
export const
|
|
35
|
-
export const
|
|
35
|
+
export const CREATE_SHELL_RUN_CONFIGURATION_SUFFIX = 'create_shell_run_configuration';
|
|
36
|
+
export const LIST_RUNNING_CONFIGURATIONS_SUFFIX = 'list_running_configurations';
|
|
37
|
+
export const STOP_RUN_CONFIGURATION_SUFFIX = 'stop_run_configuration';
|
|
38
|
+
export const RERUN_RUN_CONFIGURATION_SUFFIX = 'rerun_run_configuration';
|
|
36
39
|
|
|
37
40
|
/** All of them, in the order the guidance lists them: create, see, stop, restart. */
|
|
38
|
-
export const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
export const RUN_LIFECYCLE_SUFFIXES = [
|
|
42
|
+
CREATE_SHELL_RUN_CONFIGURATION_SUFFIX,
|
|
43
|
+
LIST_RUNNING_CONFIGURATIONS_SUFFIX,
|
|
44
|
+
STOP_RUN_CONFIGURATION_SUFFIX,
|
|
45
|
+
RERUN_RUN_CONFIGURATION_SUFFIX,
|
|
43
46
|
];
|
|
44
47
|
|
|
45
48
|
/**
|
|
46
|
-
* Which of the enhance plugin's tools this
|
|
49
|
+
* Which of the enhance plugin's tools **this project's prefix** carries in this
|
|
50
|
+
* request.
|
|
51
|
+
*
|
|
52
|
+
* 返回布尔组合而不是名字列表:提示词要的是"这段能不能写",而且每个项目各算各的
|
|
53
|
+
* —— 一个项目装了 enhance、另一个没装是可能的。
|
|
47
54
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* must never name one the model cannot call.
|
|
55
|
+
* 客户端版本差异同理:enhance 版本独立发布,旧版可能少几个工具,所以逐个判断,
|
|
56
|
+
* 提示词绝不能点名一个模型调不到的工具。
|
|
51
57
|
*
|
|
52
58
|
* @param {Record<string, unknown> | undefined} tools This request's tool record
|
|
53
59
|
* (`event.tools` from the `context` hook).
|
|
54
|
-
* @
|
|
60
|
+
* @param {string} prefix 该项目的工具前缀(含下划线)。
|
|
55
61
|
*/
|
|
56
|
-
export function
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
export function runLifecycleCapabilities(tools, prefix) {
|
|
63
|
+
const has = (suffix) =>
|
|
64
|
+
Boolean(tools) && typeof tools === 'object' && `${prefix}${suffix}` in tools;
|
|
65
|
+
return {
|
|
66
|
+
canCreateRunConfiguration: has(CREATE_SHELL_RUN_CONFIGURATION_SUFFIX),
|
|
67
|
+
canListRunningConfigurations: has(LIST_RUNNING_CONFIGURATIONS_SUFFIX),
|
|
68
|
+
canStopRunConfiguration: has(STOP_RUN_CONFIGURATION_SUFFIX),
|
|
69
|
+
canRerunRunConfiguration: has(RERUN_RUN_CONFIGURATION_SUFFIX),
|
|
70
|
+
};
|
|
59
71
|
}
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// ## Why an argument can be owned at all
|
|
11
11
|
//
|
|
12
|
-
// `
|
|
13
|
-
//
|
|
12
|
+
// `<prefix>build_project`(每个项目前缀各一份,例如 `ide_build_project`)是模型判断
|
|
13
|
+
// 刚写的代码能否编译的唯一手段,而它的结论依赖模型看不到的文件新鲜度:
|
|
14
14
|
// an INCREMENTAL build compares against the IDE's own view of the files and
|
|
15
15
|
// recompiles only what that view calls dirty. When the edit happened OUTSIDE the
|
|
16
16
|
// IDE, the view can still be clean and the build becomes a no-op that reports
|
|
@@ -47,18 +47,25 @@
|
|
|
47
47
|
/**
|
|
48
48
|
* Arguments this plugin sets on every call, and therefore must not advertise.
|
|
49
49
|
*
|
|
50
|
-
*
|
|
50
|
+
* 工具用**后缀**标识,不写完整名字:同一个 IDE 工具会以每个项目的前缀各出现一次
|
|
51
|
+
* (`ide_build_project` / `modelserver_build_project` / ...),写死 `idea_...` 会让
|
|
52
|
+
* 多项目场景里只有 root 被命中,子项目那几份原样漏过去 —— 那正是这个模块要避免的
|
|
53
|
+
* "schema 说一套、调用做另一套"。
|
|
54
|
+
*
|
|
55
|
+
* @type {ReadonlyArray<{ suffix: string, argument: string, value: unknown }>}
|
|
51
56
|
*/
|
|
52
57
|
export const FORCED_ARGUMENTS = [
|
|
53
58
|
{
|
|
54
|
-
|
|
59
|
+
suffix: 'build_project',
|
|
55
60
|
argument: 'rebuild',
|
|
56
61
|
value: true,
|
|
57
62
|
},
|
|
58
63
|
];
|
|
59
64
|
|
|
60
|
-
/**
|
|
61
|
-
export
|
|
65
|
+
/** 由前缀集合拼出这张表涉及的**全部**真实工具名。 */
|
|
66
|
+
export function forcedToolNames(prefixes) {
|
|
67
|
+
return prefixes.flatMap((prefix) => FORCED_ARGUMENTS.map((owned) => `${prefix}${owned.suffix}`));
|
|
68
|
+
}
|
|
62
69
|
|
|
63
70
|
/**
|
|
64
71
|
* Set every owned argument on one call.
|
|
@@ -75,15 +82,17 @@ export const BUILD_PROJECT_TOOL = 'idea_build_project';
|
|
|
75
82
|
*
|
|
76
83
|
* @param {unknown} tool Effective tool name for this call (`event.tool`).
|
|
77
84
|
* @param {unknown} input Mutable argument draft for this call (`event.input`).
|
|
85
|
+
* @param {readonly string[]} prefixes 本会话所有项目的工具前缀(`ide_` 等)。
|
|
78
86
|
* @returns {boolean} Whether `input` was modified.
|
|
79
87
|
*/
|
|
80
|
-
export function
|
|
88
|
+
export function applyIdeToolDefaults(tool, input, prefixes) {
|
|
81
89
|
if (typeof tool !== 'string') return false;
|
|
82
90
|
if (input === null || typeof input !== 'object') return false;
|
|
83
91
|
|
|
84
92
|
let changed = false;
|
|
85
93
|
for (const owned of FORCED_ARGUMENTS) {
|
|
86
|
-
|
|
94
|
+
const matches = prefixes.some((prefix) => tool === `${prefix}${owned.suffix}`);
|
|
95
|
+
if (!matches) continue;
|
|
87
96
|
if (input[owned.argument] === owned.value) continue;
|
|
88
97
|
input[owned.argument] = owned.value;
|
|
89
98
|
changed = true;
|
|
@@ -131,6 +140,9 @@ export function removeSchemaProperty(schema, name) {
|
|
|
131
140
|
* function of what it is given — which is also what makes it testable without a
|
|
132
141
|
* runtime.
|
|
133
142
|
*
|
|
143
|
+
* 每个项目前缀各处理一遍:同一个 IDE 工具会在工具表里以多份出现,漏掉任何一份都会
|
|
144
|
+
* 让那一份的 schema 继续广告这个旋钮。
|
|
145
|
+
*
|
|
134
146
|
* Safe to run when the IDE is absent: `get` returns undefined for a tool that is
|
|
135
147
|
* not registered, and OpenCode replays the transform after every MCP catalog
|
|
136
148
|
* refresh, so the tools are adjusted once they appear.
|
|
@@ -139,16 +151,19 @@ export function removeSchemaProperty(schema, name) {
|
|
|
139
151
|
* get: (id: string) => { input?: unknown } | undefined,
|
|
140
152
|
* update: (id: string, update: (tool: { input?: unknown }) => void) => void,
|
|
141
153
|
* }} editor
|
|
154
|
+
* @param {readonly string[]} prefixes
|
|
142
155
|
*/
|
|
143
|
-
export function hideForcedArguments(editor) {
|
|
144
|
-
for (const
|
|
145
|
-
const
|
|
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);
|
|
146
161
|
if (!tool) continue;
|
|
147
162
|
const next = removeSchemaProperty(tool.input, owned.argument);
|
|
148
163
|
if (next === tool.input) continue;
|
|
149
164
|
// Assign the whole schema: the editor documents that schemas are replaced,
|
|
150
165
|
// not mutated in place.
|
|
151
|
-
editor.update(
|
|
166
|
+
editor.update(name, (draft) => {
|
|
152
167
|
draft.input = next;
|
|
153
168
|
});
|
|
154
169
|
}
|
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
|
|
61
|
-
* passes each tool's full schema to the model, so parameter names are
|
|
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.17' },
|
|
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.
|
|
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
|