opencode-idea 0.0.10 → 0.0.12

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/src/mcp/idea.js CHANGED
@@ -6,11 +6,10 @@
6
6
  // * The IDE exposes a local MCP server. IntelliJ 2026.2+ exposes the modern
7
7
  // Streamable-HTTP endpoint at `http://127.0.0.1:<port>/stream`; older builds
8
8
  // only had the legacy SSE endpoint at `/sse`.
9
- // * OpenCode V2's remote MCP client speaks ONLY Streamable HTTP, so the plugin
10
- // registers `/stream` (see `serverConfig`). The legacy `/sse` endpoint is
11
- // unusable from OpenCode (POST returns 405 → "Error POSTing to endpoint").
12
- // * The project a call targets is selected by the HTTP header
13
- // `IJ_MCP_SERVER_PROJECT_PATH`.
9
+ // * The project a call targets is selected by the project path: the HTTP
10
+ // header `IJ_MCP_SERVER_PROJECT_PATH` for the remote transport, or the
11
+ // `IJ_MCP_SERVER_PROJECT_PATH` environment variable for the stdio bridge
12
+ // (see `serverConfig` / `stdioServerConfig`).
14
13
  // * Availability probe: a single `initialize` POST to `/stream` succeeds when
15
14
  // the MCP server is up. We do NOT check whether a specific project is open —
16
15
  // the user triggers `/open-in-idea` for the project they want, so "server is
@@ -18,6 +17,15 @@
18
17
 
19
18
  export const PROJECT_HEADER = 'IJ_MCP_SERVER_PROJECT_PATH';
20
19
 
20
+ /** Environment variable the IDE's stdio bridge uses to select the project. */
21
+ export const PROJECT_ENV = 'IJ_MCP_SERVER_PROJECT_PATH';
22
+
23
+ /** Environment variable the IDE's stdio bridge uses to reach the running server. */
24
+ export const PORT_ENV = 'IJ_MCP_SERVER_PORT';
25
+
26
+ /** Argument that puts the IDE launcher into its stdio MCP bridge mode. */
27
+ export const STDIO_ARG = 'stdioMcpServer';
28
+
21
29
  /** OpenCode MCP server name registered for the IDE. */
22
30
  export const IDEA_SERVER_NAME = 'idea';
23
31
 
@@ -44,16 +52,73 @@ const CALL_TIMEOUT_MS = 15000;
44
52
  *
45
53
  * @param {number} port
46
54
  * @param {string} projectPath
55
+ * @param {{ executionTimeoutMs?: number }} [options]
47
56
  */
48
- export function serverConfig(port, projectPath) {
49
- return {
57
+ export function serverConfig(port, projectPath, options = {}) {
58
+ /** @type {Record<string, unknown>} */
59
+ const config = {
50
60
  type: 'remote',
51
61
  url: `http://127.0.0.1:${port}${MCP_STREAM_PATH}`,
52
62
  headers: { [PROJECT_HEADER]: projectPath },
53
63
  codemode: false,
54
64
  };
65
+ return withExecutionTimeout(config, options);
66
+ }
67
+
68
+ /**
69
+ * Build the OpenCode MCP server config for the IDE's **stdio bridge**.
70
+ *
71
+ * The IDE launcher (`<App>.app/Contents/MacOS/<binary>`) with the
72
+ * `stdioMcpServer` argument runs a small proxy that speaks MCP over
73
+ * stdin/stdout and connects to the running IDE MCP server selected by the
74
+ * environment. That makes the client side a child-process pipe instead of a
75
+ * Streamable-HTTP stream, so it does not suffer the idle-close that the remote
76
+ * transport does. The project and port are carried in the environment, matching
77
+ * what the remote config carries in a header and URL.
78
+ *
79
+ * @param {string} executable absolute path to the IDE launcher
80
+ * @param {number} port IDE MCP port to bridge to
81
+ * @param {string} projectPath project root the bridge should target
82
+ * @param {{ executionTimeoutMs?: number }} [options]
83
+ */
84
+ export function stdioServerConfig(executable, port, projectPath, options = {}) {
85
+ /** @type {Record<string, unknown>} */
86
+ const config = {
87
+ type: 'local',
88
+ // OpenCode's `LocalConfig.command` is a STRING ARRAY holding the executable
89
+ // followed by its arguments (`client.ts` does `const [command, ...args] =
90
+ // config.command`). There is no separate `args` field — passing one, or a
91
+ // bare string command, fails schema validation and the server never spawns.
92
+ command: [executable, STDIO_ARG],
93
+ environment: {
94
+ [PROJECT_ENV]: projectPath,
95
+ [PORT_ENV]: String(port),
96
+ },
97
+ codemode: false,
98
+ };
99
+ return withExecutionTimeout(config, options);
55
100
  }
56
101
 
102
+ /**
103
+ * Attach OpenCode's per-server execution timeout to an MCP config.
104
+ *
105
+ * OpenCode defaults this to **12 hours**. The IDE's stdio bridge never answers a
106
+ * call whose upstream IDE connection has dropped (it logs on stderr but sends no
107
+ * JSON-RPC response), so without a cap a call made after the IDE died would sit
108
+ * unresolved for the whole default. A finite cap turns that permanent hang into
109
+ * an ordinary timeout error.
110
+ *
111
+ * @param {Record<string, unknown>} config
112
+ * @param {{ executionTimeoutMs?: number }} options
113
+ */
114
+ function withExecutionTimeout(config, options) {
115
+ if (typeof options.executionTimeoutMs === 'number') {
116
+ return { ...config, timeout: { execution: options.executionTimeoutMs } };
117
+ }
118
+ return config;
119
+ }
120
+
121
+
57
122
  /**
58
123
  * Is an IDE MCP server listening on `port`? One Streamable-HTTP `initialize`
59
124
  * POST decides it: any 2xx JSON reply means the server is up.
@@ -81,7 +146,7 @@ export async function probePort(port, options = {}) {
81
146
  params: {
82
147
  protocolVersion: PROTOCOL_VERSION,
83
148
  capabilities: {},
84
- clientInfo: { name: 'opencode-idea', version: '0.0.10' },
149
+ clientInfo: { name: 'opencode-idea', version: '0.0.12' },
85
150
  },
86
151
  }),
87
152
  signal: controller.signal,
@@ -132,34 +197,43 @@ export async function callTool(port, projectPath, name, args = {}, options = {})
132
197
  const fetchImpl = options.fetchImpl ?? fetch;
133
198
  const controller = new AbortController();
134
199
  const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? CALL_TIMEOUT_MS);
135
- const post = (payload) =>
200
+ const post = (payload, sessionId) =>
136
201
  fetchImpl(`http://127.0.0.1:${port}${MCP_STREAM_PATH}`, {
137
202
  method: 'POST',
138
203
  headers: {
139
204
  'Content-Type': 'application/json',
140
205
  Accept: 'application/json, text/event-stream',
141
206
  [PROJECT_HEADER]: projectPath,
207
+ ...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}),
142
208
  },
143
209
  body: JSON.stringify(payload),
144
210
  signal: controller.signal,
145
211
  });
146
212
  try {
147
- await post({
213
+ const initialized = await post({
148
214
  jsonrpc: '2.0',
149
215
  id: 1,
150
216
  method: 'initialize',
151
217
  params: {
152
218
  protocolVersion: PROTOCOL_VERSION,
153
219
  capabilities: {},
154
- clientInfo: { name: 'opencode-idea', version: '0.0.10' },
220
+ clientInfo: { name: 'opencode-idea', version: '0.0.12' },
155
221
  },
156
222
  });
157
- const response = await post({
158
- jsonrpc: '2.0',
159
- id: 2,
160
- method: 'tools/call',
161
- params: { name, arguments: args },
162
- });
223
+ // The IDE's Streamable-HTTP endpoint binds follow-up requests to the session
224
+ // created by `initialize`: without echoing its `Mcp-Session-Id` header the
225
+ // server rejects the call with "Bad Request: Server not initialized"
226
+ // (verified against IntelliJ IDEA 2026.2.3).
227
+ const sessionId = initialized.headers?.get?.('mcp-session-id') ?? undefined;
228
+ const response = await post(
229
+ {
230
+ jsonrpc: '2.0',
231
+ id: 2,
232
+ method: 'tools/call',
233
+ params: { name, arguments: args },
234
+ },
235
+ sessionId,
236
+ );
163
237
  if (!response.ok) return undefined;
164
238
  const message = await parseToolResponse(response);
165
239
  if (!message || message.error) return undefined;