@evanovation/open-cursor 2.4.15
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/LICENSE +28 -0
- package/README.md +270 -0
- package/dist/cli/discover.js +527 -0
- package/dist/cli/mcptool.js +10339 -0
- package/dist/cli/opencode-cursor.js +2989 -0
- package/dist/index.js +20588 -0
- package/dist/plugin-entry.js +19848 -0
- package/package.json +82 -0
- package/scripts/cursor-agent-runner.mjs +272 -0
- package/scripts/sdk-runner.mjs +412 -0
- package/src/acp/metrics.ts +83 -0
- package/src/acp/sessions.ts +107 -0
- package/src/acp/tools.ts +209 -0
- package/src/auth.ts +175 -0
- package/src/cli/discover.ts +53 -0
- package/src/cli/mcptool.ts +133 -0
- package/src/cli/model-discovery.ts +71 -0
- package/src/cli/opencode-cursor.ts +1195 -0
- package/src/client/cursor-agent-child.ts +459 -0
- package/src/client/sdk-child.ts +550 -0
- package/src/client/simple.ts +293 -0
- package/src/commands/status.ts +39 -0
- package/src/index.ts +39 -0
- package/src/mcp/client-manager.ts +166 -0
- package/src/mcp/config.ts +169 -0
- package/src/mcp/tool-bridge.ts +133 -0
- package/src/models/config.ts +64 -0
- package/src/models/discovery.ts +105 -0
- package/src/models/index.ts +3 -0
- package/src/models/pricing.ts +196 -0
- package/src/models/sync.ts +247 -0
- package/src/models/types.ts +11 -0
- package/src/models/variants.ts +446 -0
- package/src/plugin-entry.ts +28 -0
- package/src/plugin-toggle.ts +81 -0
- package/src/plugin.ts +2802 -0
- package/src/provider/backend.ts +71 -0
- package/src/provider/boundary.ts +168 -0
- package/src/provider/passthrough-tracker.ts +38 -0
- package/src/provider/runtime-interception.ts +818 -0
- package/src/provider/tool-loop-guard.ts +644 -0
- package/src/provider/tool-schema-compat.ts +800 -0
- package/src/provider.ts +268 -0
- package/src/proxy/formatter.ts +60 -0
- package/src/proxy/handler.ts +29 -0
- package/src/proxy/incremental-prompt.ts +74 -0
- package/src/proxy/prompt-builder.ts +204 -0
- package/src/proxy/server.ts +207 -0
- package/src/proxy/session-resume.ts +312 -0
- package/src/proxy/tool-loop.ts +359 -0
- package/src/proxy/types.ts +13 -0
- package/src/services/toast-service.ts +81 -0
- package/src/streaming/ai-sdk-parts.ts +109 -0
- package/src/streaming/delta-tracker.ts +89 -0
- package/src/streaming/line-buffer.ts +44 -0
- package/src/streaming/openai-sse.ts +118 -0
- package/src/streaming/parser.ts +22 -0
- package/src/streaming/types.ts +158 -0
- package/src/tools/core/executor.ts +25 -0
- package/src/tools/core/registry.ts +27 -0
- package/src/tools/core/types.ts +31 -0
- package/src/tools/defaults.ts +954 -0
- package/src/tools/discovery.ts +140 -0
- package/src/tools/executors/cli.ts +59 -0
- package/src/tools/executors/local.ts +25 -0
- package/src/tools/executors/mcp.ts +39 -0
- package/src/tools/executors/sdk.ts +39 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/registry.ts +34 -0
- package/src/tools/router.ts +123 -0
- package/src/tools/schema.ts +58 -0
- package/src/tools/skills/loader.ts +61 -0
- package/src/tools/skills/resolver.ts +21 -0
- package/src/tools/types.ts +29 -0
- package/src/types.ts +8 -0
- package/src/usage.ts +112 -0
- package/src/utils/binary.ts +71 -0
- package/src/utils/errors.ts +224 -0
- package/src/utils/logger.ts +191 -0
- package/src/utils/perf.ts +76 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { LineBuffer } from '../streaming/line-buffer.js';
|
|
3
|
+
import { parseStreamJsonLine } from '../streaming/parser.js';
|
|
4
|
+
import {
|
|
5
|
+
extractText,
|
|
6
|
+
isAssistantText,
|
|
7
|
+
type StreamJsonEvent,
|
|
8
|
+
} from '../streaming/types.js';
|
|
9
|
+
import { createLogger } from '../utils/logger.js';
|
|
10
|
+
import { formatShellCommandForPlatform, resolveCursorAgentBinary } from '../utils/binary.js';
|
|
11
|
+
|
|
12
|
+
export interface CursorClientConfig {
|
|
13
|
+
timeout?: number;
|
|
14
|
+
maxRetries?: number;
|
|
15
|
+
streamOutput?: boolean;
|
|
16
|
+
cursorAgentPath?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CursorResponse {
|
|
20
|
+
content: string;
|
|
21
|
+
done: boolean;
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SimpleCursorClient {
|
|
26
|
+
private config: Required<CursorClientConfig>;
|
|
27
|
+
private log: ReturnType<typeof createLogger>;
|
|
28
|
+
|
|
29
|
+
constructor(config: CursorClientConfig = {}) {
|
|
30
|
+
this.config = {
|
|
31
|
+
timeout: 30000,
|
|
32
|
+
maxRetries: 3,
|
|
33
|
+
streamOutput: true,
|
|
34
|
+
cursorAgentPath: resolveCursorAgentBinary(),
|
|
35
|
+
...config
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
this.log = createLogger('cursor-client');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async *executePromptStream(prompt: string, options: {
|
|
42
|
+
cwd?: string;
|
|
43
|
+
model?: string;
|
|
44
|
+
mode?: 'default' | 'plan' | 'ask';
|
|
45
|
+
resumeId?: string;
|
|
46
|
+
} = {}): AsyncGenerator<StreamJsonEvent, void, unknown> {
|
|
47
|
+
// Input validation
|
|
48
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
49
|
+
throw new Error('Invalid prompt: must be a non-empty string');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
cwd = process.cwd(),
|
|
54
|
+
model = 'auto',
|
|
55
|
+
mode = 'default',
|
|
56
|
+
resumeId
|
|
57
|
+
} = options;
|
|
58
|
+
|
|
59
|
+
const args = [
|
|
60
|
+
'--print',
|
|
61
|
+
'--output-format',
|
|
62
|
+
'stream-json',
|
|
63
|
+
'--stream-partial-output',
|
|
64
|
+
'--model',
|
|
65
|
+
model
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
if (mode === 'plan') {
|
|
69
|
+
args.push('--plan');
|
|
70
|
+
} else if (mode === 'ask') {
|
|
71
|
+
args.push('--mode', 'ask');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (resumeId) {
|
|
75
|
+
args.push('--resume', resumeId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.log.debug('Executing prompt stream', { promptLength: prompt.length, mode, model });
|
|
79
|
+
|
|
80
|
+
const child = spawn(formatShellCommandForPlatform(this.config.cursorAgentPath), args, {
|
|
81
|
+
cwd,
|
|
82
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
83
|
+
shell: process.platform === 'win32',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (prompt) {
|
|
87
|
+
child.stdin.write(prompt);
|
|
88
|
+
child.stdin.end();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let processError: Error | null = null;
|
|
92
|
+
const lineBuffer = new LineBuffer();
|
|
93
|
+
|
|
94
|
+
// Add stderr handling
|
|
95
|
+
child.stderr.on('data', (data) => {
|
|
96
|
+
const errorMsg = data.toString();
|
|
97
|
+
this.log.error('cursor-agent stderr', { error: errorMsg });
|
|
98
|
+
processError = new Error(errorMsg);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Add timeout
|
|
102
|
+
const timeoutId = setTimeout(() => {
|
|
103
|
+
child.kill('SIGTERM');
|
|
104
|
+
processError = new Error(`Timeout after ${this.config.timeout}ms`);
|
|
105
|
+
}, this.config.timeout);
|
|
106
|
+
|
|
107
|
+
const streamEnded = new Promise<number | null>((resolve) => {
|
|
108
|
+
child.on('close', (code) => {
|
|
109
|
+
clearTimeout(timeoutId);
|
|
110
|
+
if (code !== 0 && !processError) {
|
|
111
|
+
this.log.error('cursor-agent exited with non-zero code', { code });
|
|
112
|
+
processError = new Error(`cursor-agent exited with code ${code}`);
|
|
113
|
+
}
|
|
114
|
+
resolve(code);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
child.on('error', (error) => {
|
|
118
|
+
clearTimeout(timeoutId);
|
|
119
|
+
this.log.error('cursor-agent process error', { error: error.message });
|
|
120
|
+
processError = error;
|
|
121
|
+
resolve(null);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
for await (const chunk of child.stdout) {
|
|
126
|
+
for (const line of lineBuffer.push(chunk)) {
|
|
127
|
+
const event = parseStreamJsonLine(line);
|
|
128
|
+
if (event) {
|
|
129
|
+
yield event;
|
|
130
|
+
} else {
|
|
131
|
+
this.log.warn('Invalid JSON from cursor-agent', { line: line.substring(0, 100) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const line of lineBuffer.flush()) {
|
|
137
|
+
const event = parseStreamJsonLine(line);
|
|
138
|
+
if (event) {
|
|
139
|
+
yield event;
|
|
140
|
+
} else {
|
|
141
|
+
this.log.warn('Invalid JSON from cursor-agent', { line: line.substring(0, 100) });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await streamEnded;
|
|
146
|
+
|
|
147
|
+
if (processError) {
|
|
148
|
+
throw processError;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async executePrompt(prompt: string, options: {
|
|
153
|
+
cwd?: string;
|
|
154
|
+
model?: string;
|
|
155
|
+
mode?: 'default' | 'plan' | 'ask';
|
|
156
|
+
resumeId?: string;
|
|
157
|
+
} = {}): Promise<CursorResponse> {
|
|
158
|
+
// Input validation
|
|
159
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
160
|
+
throw new Error('Invalid prompt: must be a non-empty string');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const {
|
|
164
|
+
cwd = process.cwd(),
|
|
165
|
+
model = 'auto',
|
|
166
|
+
mode = 'default',
|
|
167
|
+
resumeId
|
|
168
|
+
} = options;
|
|
169
|
+
|
|
170
|
+
const args = [
|
|
171
|
+
'--print',
|
|
172
|
+
'--output-format',
|
|
173
|
+
'stream-json',
|
|
174
|
+
'--stream-partial-output',
|
|
175
|
+
'--model',
|
|
176
|
+
model
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
if (mode === 'plan') {
|
|
180
|
+
args.push('--plan');
|
|
181
|
+
} else if (mode === 'ask') {
|
|
182
|
+
args.push('--mode', 'ask');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (resumeId) {
|
|
186
|
+
args.push('--resume', resumeId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this.log.debug('Executing prompt', { promptLength: prompt.length, mode, model });
|
|
190
|
+
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
const child = spawn(formatShellCommandForPlatform(this.config.cursorAgentPath), args, {
|
|
193
|
+
cwd,
|
|
194
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
195
|
+
shell: process.platform === 'win32',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
let stdoutBuffer = '';
|
|
199
|
+
let stderrBuffer = '';
|
|
200
|
+
|
|
201
|
+
if (prompt) {
|
|
202
|
+
child.stdin.write(prompt);
|
|
203
|
+
child.stdin.end();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const timeout = setTimeout(() => {
|
|
207
|
+
child.kill('SIGTERM');
|
|
208
|
+
reject(new Error(`Timeout after ${this.config.timeout}ms`));
|
|
209
|
+
}, this.config.timeout);
|
|
210
|
+
|
|
211
|
+
child.stdout.on('data', (data) => {
|
|
212
|
+
stdoutBuffer += data.toString();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
child.stderr.on('data', (data) => {
|
|
216
|
+
stderrBuffer += data.toString();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
child.on('close', (code) => {
|
|
220
|
+
clearTimeout(timeout);
|
|
221
|
+
|
|
222
|
+
if (code !== 0) {
|
|
223
|
+
reject(new Error(`cursor-agent exited with code ${code}: ${stderrBuffer}`));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const lines = stdoutBuffer.trim().split('\n');
|
|
229
|
+
let content = '';
|
|
230
|
+
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
if (line.trim()) {
|
|
233
|
+
const event = parseStreamJsonLine(line);
|
|
234
|
+
if (event && isAssistantText(event)) {
|
|
235
|
+
content = extractText(event);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
resolve({
|
|
241
|
+
content,
|
|
242
|
+
done: true
|
|
243
|
+
});
|
|
244
|
+
} catch (error) {
|
|
245
|
+
reject(new Error(`Failed to parse cursor-agent output: ${error}`));
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
child.on('error', (error) => {
|
|
250
|
+
clearTimeout(timeout);
|
|
251
|
+
reject(error);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async getAvailableModels(): Promise<Array<{ id: string; name: string }>> {
|
|
257
|
+
return [
|
|
258
|
+
{ id: 'auto', name: 'Cursor Agent Auto' },
|
|
259
|
+
{ id: 'composer-1.5', name: 'Composer 1.5' },
|
|
260
|
+
{ id: 'opus-4.6-thinking', name: 'Claude 4.6 Opus (Thinking)' },
|
|
261
|
+
{ id: 'opus-4.6', name: 'Claude 4.6 Opus' },
|
|
262
|
+
{ id: 'sonnet-4.6', name: 'Claude 4.6 Sonnet' },
|
|
263
|
+
{ id: 'sonnet-4.6-thinking', name: 'Claude 4.6 Sonnet (Thinking)' },
|
|
264
|
+
{ id: 'opus-4.5', name: 'Claude 4.5 Opus' },
|
|
265
|
+
{ id: 'opus-4.5-thinking', name: 'Claude 4.5 Opus (Thinking)' },
|
|
266
|
+
{ id: 'sonnet-4.5', name: 'Claude 4.5 Sonnet' },
|
|
267
|
+
{ id: 'sonnet-4.5-thinking', name: 'Claude 4.5 Sonnet (Thinking)' },
|
|
268
|
+
{ id: 'gpt-5.4-high', name: 'GPT-5.4 High' },
|
|
269
|
+
{ id: 'gpt-5.4-medium', name: 'GPT-5.4' },
|
|
270
|
+
{ id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex' },
|
|
271
|
+
{ id: 'gpt-5.2', name: 'GPT-5.2' },
|
|
272
|
+
{ id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro' },
|
|
273
|
+
{ id: 'gemini-3-pro', name: 'Gemini 3 Pro' },
|
|
274
|
+
{ id: 'gemini-3-flash', name: 'Gemini 3 Flash' },
|
|
275
|
+
{ id: 'grok', name: 'Grok' },
|
|
276
|
+
{ id: 'kimi-k2.5', name: 'Kimi K2.5' },
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async validateInstallation(): Promise<boolean> {
|
|
281
|
+
try {
|
|
282
|
+
const testResponse = await this.executePrompt('test', { model: 'auto' });
|
|
283
|
+
return !!testResponse.content;
|
|
284
|
+
} catch (error) {
|
|
285
|
+
this.log.error('Cursor installation validation failed:', error);
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export const createSimpleCursorClient = (config: CursorClientConfig = {}) => {
|
|
292
|
+
return new SimpleCursorClient(config);
|
|
293
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/commands/status.ts
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
|
+
import { getAuthFilePath } from "../auth";
|
|
5
|
+
import { createLogger } from "../utils/logger";
|
|
6
|
+
|
|
7
|
+
const log = createLogger("status");
|
|
8
|
+
|
|
9
|
+
export interface AuthStatus {
|
|
10
|
+
authenticated: boolean;
|
|
11
|
+
authFilePath: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function checkAuthStatus(): AuthStatus {
|
|
16
|
+
const authFilePath = getAuthFilePath();
|
|
17
|
+
const exists = existsSync(authFilePath);
|
|
18
|
+
|
|
19
|
+
log.debug("Checking auth status", { path: authFilePath });
|
|
20
|
+
|
|
21
|
+
if (exists) {
|
|
22
|
+
return {
|
|
23
|
+
authenticated: true,
|
|
24
|
+
authFilePath,
|
|
25
|
+
message: `✓ Cursor: Authenticated\n Auth file: ${authFilePath}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
authenticated: false,
|
|
31
|
+
authFilePath,
|
|
32
|
+
message: `✗ Cursor: Not authenticated\n Run: opencode auth login cursor-acp`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatStatusOutput(): string {
|
|
37
|
+
const status = checkAuthStatus();
|
|
38
|
+
return status.message;
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export { CursorPlugin } from "./plugin.js";
|
|
2
|
+
export { createCursorProvider, cursor } from "./provider.js";
|
|
3
|
+
export type { ProviderOptions } from "./provider.js";
|
|
4
|
+
export { createProxyServer, findAvailablePort } from "./proxy/server.js";
|
|
5
|
+
export { parseOpenAIRequest } from "./proxy/handler.js";
|
|
6
|
+
export type { ParsedRequest } from "./proxy/handler.js";
|
|
7
|
+
export { createChatCompletionResponse, createChatCompletionChunk } from "./proxy/formatter.js";
|
|
8
|
+
export { verifyCursorAuth } from "./auth.js";
|
|
9
|
+
export type { AuthResult } from "./auth.js";
|
|
10
|
+
export { checkAuthStatus, formatStatusOutput } from "./commands/status";
|
|
11
|
+
export type { AuthStatus } from "./commands/status";
|
|
12
|
+
|
|
13
|
+
// Utilities
|
|
14
|
+
export { createLogger } from "./utils/logger";
|
|
15
|
+
export type { Logger } from "./utils/logger";
|
|
16
|
+
export { parseAgentError, formatErrorForUser, stripAnsi } from "./utils/errors";
|
|
17
|
+
export type { ParsedError, ErrorType } from "./utils/errors";
|
|
18
|
+
|
|
19
|
+
// Streaming utilities
|
|
20
|
+
export { LineBuffer } from "./streaming/line-buffer.js";
|
|
21
|
+
export { parseStreamJsonLine } from "./streaming/parser.js";
|
|
22
|
+
export { DeltaTracker } from "./streaming/delta-tracker.js";
|
|
23
|
+
export { StreamToSseConverter, formatSseChunk, formatSseDone } from "./streaming/openai-sse.js";
|
|
24
|
+
export { StreamToAiSdkParts } from "./streaming/ai-sdk-parts.js";
|
|
25
|
+
export type {
|
|
26
|
+
StreamJsonAssistantEvent,
|
|
27
|
+
StreamJsonEvent,
|
|
28
|
+
StreamJsonResultEvent,
|
|
29
|
+
StreamJsonSystemEvent,
|
|
30
|
+
StreamJsonThinkingEvent,
|
|
31
|
+
StreamJsonToolCallEvent,
|
|
32
|
+
StreamJsonUserEvent,
|
|
33
|
+
} from "./streaming/types.js";
|
|
34
|
+
|
|
35
|
+
// Default export for OpenCode plugin usage
|
|
36
|
+
export { CursorPlugin as default } from "./plugin.js";
|
|
37
|
+
|
|
38
|
+
// Backward compatibility
|
|
39
|
+
export { default as createCursorProviderCompat } from "./provider.js";
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createLogger } from "../utils/logger.js";
|
|
2
|
+
import type { McpServerConfig } from "./config.js";
|
|
3
|
+
|
|
4
|
+
const log = createLogger("mcp:client-manager");
|
|
5
|
+
|
|
6
|
+
export interface McpToolInfo {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
inputSchema?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface DiscoveredTool extends McpToolInfo {
|
|
13
|
+
serverName: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ServerConnection {
|
|
17
|
+
client: any;
|
|
18
|
+
tools: McpToolInfo[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface McpClientManagerDeps {
|
|
22
|
+
createClient: () => any;
|
|
23
|
+
createTransport: (config: McpServerConfig) => any;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let defaultDeps: McpClientManagerDeps | null = null;
|
|
27
|
+
|
|
28
|
+
async function loadDefaultDeps(): Promise<McpClientManagerDeps> {
|
|
29
|
+
if (defaultDeps) return defaultDeps;
|
|
30
|
+
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
|
|
31
|
+
const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
|
|
32
|
+
|
|
33
|
+
defaultDeps = {
|
|
34
|
+
createClient: () =>
|
|
35
|
+
new Client({ name: "open-cursor", version: "1.0.0" }, { capabilities: {} }),
|
|
36
|
+
createTransport: (config: McpServerConfig) => {
|
|
37
|
+
if (config.type === "local") {
|
|
38
|
+
return new StdioClientTransport({
|
|
39
|
+
command: config.command[0],
|
|
40
|
+
args: config.command.slice(1),
|
|
41
|
+
env: { ...process.env, ...(config.environment ?? {}) },
|
|
42
|
+
stderr: "pipe",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// Remote servers: StreamableHTTPClientTransport can be added later.
|
|
46
|
+
throw new Error(`Remote MCP transport not yet implemented for ${config.name}`);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
return defaultDeps;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class McpClientManager {
|
|
53
|
+
private connections = new Map<string, ServerConnection>();
|
|
54
|
+
private deps: McpClientManagerDeps | null;
|
|
55
|
+
|
|
56
|
+
constructor(deps?: McpClientManagerDeps) {
|
|
57
|
+
this.deps = deps ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async connectServer(config: McpServerConfig): Promise<void> {
|
|
61
|
+
if (this.connections.has(config.name)) {
|
|
62
|
+
log.debug("Server already connected, skipping", { server: config.name });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Lazy-load MCP SDK if no deps were injected
|
|
67
|
+
if (!this.deps) {
|
|
68
|
+
try {
|
|
69
|
+
this.deps = await loadDefaultDeps();
|
|
70
|
+
} catch (err) {
|
|
71
|
+
log.warn("Failed to load MCP SDK", { error: String(err) });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const deps = this.deps;
|
|
77
|
+
let client: any;
|
|
78
|
+
try {
|
|
79
|
+
client = deps.createClient();
|
|
80
|
+
const transport = deps.createTransport(config);
|
|
81
|
+
await client.connect(transport);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
log.warn("MCP server connection failed", {
|
|
84
|
+
server: config.name,
|
|
85
|
+
error: String(err),
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let tools: McpToolInfo[] = [];
|
|
91
|
+
try {
|
|
92
|
+
const result = await client.listTools();
|
|
93
|
+
tools = result?.tools ?? [];
|
|
94
|
+
log.info("MCP server connected", {
|
|
95
|
+
server: config.name,
|
|
96
|
+
tools: tools.length,
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
log.warn("MCP tool discovery failed", {
|
|
100
|
+
server: config.name,
|
|
101
|
+
error: String(err),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.connections.set(config.name, { client, tools });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
listTools(): DiscoveredTool[] {
|
|
109
|
+
const all: DiscoveredTool[] = [];
|
|
110
|
+
for (const [serverName, conn] of this.connections) {
|
|
111
|
+
for (const tool of conn.tools) {
|
|
112
|
+
all.push({ ...tool, serverName });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return all;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async callTool(
|
|
119
|
+
serverName: string,
|
|
120
|
+
toolName: string,
|
|
121
|
+
args: Record<string, unknown>,
|
|
122
|
+
): Promise<string> {
|
|
123
|
+
const conn = this.connections.get(serverName);
|
|
124
|
+
if (!conn) {
|
|
125
|
+
return `Error: MCP server "${serverName}" not connected`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const result = await conn.client.callTool({
|
|
130
|
+
name: toolName,
|
|
131
|
+
arguments: args,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// MCP callTool returns { content: Array<{ type, text }> }
|
|
135
|
+
if (Array.isArray(result?.content)) {
|
|
136
|
+
return result.content
|
|
137
|
+
.map((c: any) => (c.type === "text" ? c.text : JSON.stringify(c)))
|
|
138
|
+
.join("\n");
|
|
139
|
+
}
|
|
140
|
+
return typeof result === "string" ? result : JSON.stringify(result);
|
|
141
|
+
} catch (err: any) {
|
|
142
|
+
log.warn("MCP tool call failed", {
|
|
143
|
+
server: serverName,
|
|
144
|
+
tool: toolName,
|
|
145
|
+
error: String(err?.message || err),
|
|
146
|
+
});
|
|
147
|
+
return `Error: MCP tool "${toolName}" failed: ${err?.message || err}`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async disconnectAll(): Promise<void> {
|
|
152
|
+
for (const [name, conn] of this.connections) {
|
|
153
|
+
try {
|
|
154
|
+
await conn.client.close();
|
|
155
|
+
log.debug("MCP server disconnected", { server: name });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
log.debug("MCP server disconnect failed", { server: name, error: String(err) });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.connections.clear();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get connectedServers(): string[] {
|
|
164
|
+
return Array.from(this.connections.keys());
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync as nodeExistsSync,
|
|
3
|
+
readFileSync as nodeReadFileSync,
|
|
4
|
+
} from "node:fs";
|
|
5
|
+
import { resolveOpenCodeConfigPath } from "../plugin-toggle.js";
|
|
6
|
+
import { createLogger } from "../utils/logger.js";
|
|
7
|
+
|
|
8
|
+
const log = createLogger("mcp:config");
|
|
9
|
+
|
|
10
|
+
export type McpLocalServerConfig = {
|
|
11
|
+
name: string;
|
|
12
|
+
type: "local";
|
|
13
|
+
command: string[];
|
|
14
|
+
environment?: Record<string, string>;
|
|
15
|
+
timeout?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type McpRemoteServerConfig = {
|
|
19
|
+
name: string;
|
|
20
|
+
type: "remote";
|
|
21
|
+
url: string;
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
timeout?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type McpServerConfig = McpLocalServerConfig | McpRemoteServerConfig;
|
|
27
|
+
|
|
28
|
+
interface ReadMcpConfigsDeps {
|
|
29
|
+
configJson?: string;
|
|
30
|
+
existsSync?: (path: string) => boolean;
|
|
31
|
+
readFileSync?: (path: string, enc: BufferEncoding) => string;
|
|
32
|
+
env?: NodeJS.ProcessEnv;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readMcpConfigs(deps: ReadMcpConfigsDeps = {}): McpServerConfig[] {
|
|
36
|
+
let raw: string;
|
|
37
|
+
|
|
38
|
+
if (deps.configJson != null) {
|
|
39
|
+
raw = deps.configJson;
|
|
40
|
+
} else {
|
|
41
|
+
const exists = deps.existsSync ?? nodeExistsSync;
|
|
42
|
+
const readFile = deps.readFileSync ?? nodeReadFileSync;
|
|
43
|
+
const configPath = resolveOpenCodeConfigPath(deps.env ?? process.env);
|
|
44
|
+
if (!exists(configPath)) return [];
|
|
45
|
+
try {
|
|
46
|
+
raw = readFile(configPath, "utf8");
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let parsed: Record<string, unknown>;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(raw);
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const mcpSection = parsed.mcp;
|
|
60
|
+
if (!mcpSection || typeof mcpSection !== "object" || Array.isArray(mcpSection)) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const configs: McpServerConfig[] = [];
|
|
65
|
+
|
|
66
|
+
for (const [name, entry] of Object.entries(mcpSection as Record<string, unknown>)) {
|
|
67
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
68
|
+
const e = entry as Record<string, unknown>;
|
|
69
|
+
|
|
70
|
+
if (e.enabled === false) continue;
|
|
71
|
+
|
|
72
|
+
if (e.type === "local" && Array.isArray(e.command) && e.command.length > 0) {
|
|
73
|
+
configs.push({
|
|
74
|
+
name,
|
|
75
|
+
type: "local",
|
|
76
|
+
command: e.command as string[],
|
|
77
|
+
environment: isStringRecord(e.environment) ? e.environment : undefined,
|
|
78
|
+
timeout: typeof e.timeout === "number" ? e.timeout : undefined,
|
|
79
|
+
});
|
|
80
|
+
} else if (e.type === "remote" && typeof e.url === "string") {
|
|
81
|
+
configs.push({
|
|
82
|
+
name,
|
|
83
|
+
type: "remote",
|
|
84
|
+
url: e.url,
|
|
85
|
+
headers: isStringRecord(e.headers) ? e.headers : undefined,
|
|
86
|
+
timeout: typeof e.timeout === "number" ? e.timeout : undefined,
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
log.debug("Skipping unrecognised MCP config entry", { name, type: e.type });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return configs;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let _subagentCache: { names: string[]; expiry: number } | null = null;
|
|
97
|
+
const SUBAGENT_CACHE_TTL_MS = 60_000;
|
|
98
|
+
|
|
99
|
+
/** Clear cached subagent names (for testing only). */
|
|
100
|
+
export function _resetSubagentCache(): void {
|
|
101
|
+
_subagentCache = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface ReadSubagentNamesDeps {
|
|
105
|
+
configJson?: string;
|
|
106
|
+
existsSync?: (path: string) => boolean;
|
|
107
|
+
readFileSync?: (path: string, enc: BufferEncoding) => string;
|
|
108
|
+
env?: NodeJS.ProcessEnv;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function readSubagentNames(deps: ReadSubagentNamesDeps = {}): string[] {
|
|
112
|
+
const useCache = deps.configJson == null;
|
|
113
|
+
if (useCache && _subagentCache && Date.now() < _subagentCache.expiry) {
|
|
114
|
+
return _subagentCache.names;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const result = readSubagentNamesUncached(deps);
|
|
118
|
+
|
|
119
|
+
if (useCache) {
|
|
120
|
+
_subagentCache = { names: result, expiry: Date.now() + SUBAGENT_CACHE_TTL_MS };
|
|
121
|
+
}
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readSubagentNamesUncached(deps: ReadSubagentNamesDeps): string[] {
|
|
126
|
+
let raw: string;
|
|
127
|
+
|
|
128
|
+
if (deps.configJson != null) {
|
|
129
|
+
raw = deps.configJson;
|
|
130
|
+
} else {
|
|
131
|
+
const exists = deps.existsSync ?? nodeExistsSync;
|
|
132
|
+
const readFile = deps.readFileSync ?? nodeReadFileSync;
|
|
133
|
+
const configPath = resolveOpenCodeConfigPath(deps.env ?? process.env);
|
|
134
|
+
if (!exists(configPath)) return ["general-purpose"];
|
|
135
|
+
try {
|
|
136
|
+
raw = readFile(configPath, "utf8");
|
|
137
|
+
} catch {
|
|
138
|
+
return ["general-purpose"];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let parsed: Record<string, unknown>;
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(raw);
|
|
145
|
+
} catch {
|
|
146
|
+
return ["general-purpose"];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const agentSection = parsed.agent;
|
|
150
|
+
if (!agentSection || typeof agentSection !== "object" || Array.isArray(agentSection)) {
|
|
151
|
+
return ["general-purpose"];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const agents = agentSection as Record<string, unknown>;
|
|
155
|
+
const names = Object.keys(agents);
|
|
156
|
+
if (names.length === 0) return ["general-purpose"];
|
|
157
|
+
|
|
158
|
+
const subagentNames = names.filter((name) => {
|
|
159
|
+
const entry = agents[name];
|
|
160
|
+
return entry && typeof entry === "object" && !Array.isArray(entry)
|
|
161
|
+
&& (entry as Record<string, unknown>).mode === "subagent";
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return subagentNames.length > 0 ? subagentNames : names;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isStringRecord(v: unknown): v is Record<string, string> {
|
|
168
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
169
|
+
}
|