@iinm/plain-agent 1.8.4 → 1.8.6
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/bin/plain +1 -1
- package/package.json +8 -9
- package/sandbox/bin/plain-sandbox +13 -0
- package/src/agent.d.ts +52 -0
- package/src/agent.mjs +204 -0
- package/src/agentLoop.mjs +419 -0
- package/src/agentState.mjs +41 -0
- package/src/claudeCodePlugin.mjs +164 -0
- package/src/cliArgs.mjs +175 -0
- package/src/cliBatch.mjs +147 -0
- package/src/cliCommands.mjs +283 -0
- package/src/cliCompleter.mjs +227 -0
- package/src/cliCost.mjs +309 -0
- package/src/cliFormatter.mjs +518 -0
- package/src/cliInteractive.mjs +533 -0
- package/src/cliInterruptTransform.mjs +51 -0
- package/src/cliMuteTransform.mjs +26 -0
- package/src/cliPasteTransform.mjs +183 -0
- package/src/config.d.ts +36 -0
- package/src/config.mjs +197 -0
- package/src/context/loadAgentRoles.mjs +267 -0
- package/src/context/loadPrompts.mjs +303 -0
- package/src/context/loadUserMessageContext.mjs +147 -0
- package/src/costTracker.mjs +210 -0
- package/src/env.mjs +44 -0
- package/src/main.mjs +281 -0
- package/src/mcpClient.mjs +351 -0
- package/src/mcpIntegration.mjs +160 -0
- package/src/model.d.ts +109 -0
- package/src/modelCaller.mjs +32 -0
- package/src/modelDefinition.d.ts +92 -0
- package/src/prompt.mjs +138 -0
- package/src/providers/anthropic.d.ts +248 -0
- package/src/providers/anthropic.mjs +587 -0
- package/src/providers/bedrock.d.ts +249 -0
- package/src/providers/bedrock.mjs +700 -0
- package/src/providers/gemini.d.ts +208 -0
- package/src/providers/gemini.mjs +754 -0
- package/src/providers/openai.d.ts +281 -0
- package/src/providers/openai.mjs +544 -0
- package/src/providers/openaiCompatible.d.ts +147 -0
- package/src/providers/openaiCompatible.mjs +652 -0
- package/src/providers/platform/awsSigV4.mjs +184 -0
- package/src/providers/platform/azure.mjs +42 -0
- package/src/providers/platform/bedrock.mjs +78 -0
- package/src/providers/platform/googleCloud.mjs +34 -0
- package/src/subagent.mjs +265 -0
- package/src/tmpfile.mjs +27 -0
- package/src/tool.d.ts +74 -0
- package/src/toolExecutor.mjs +236 -0
- package/src/toolInputValidator.mjs +183 -0
- package/src/toolUseApprover.mjs +99 -0
- package/src/tools/askURL.mjs +209 -0
- package/src/tools/askWeb.mjs +208 -0
- package/src/tools/compactContext.d.ts +4 -0
- package/src/tools/compactContext.mjs +87 -0
- package/src/tools/execCommand.d.ts +22 -0
- package/src/tools/execCommand.mjs +200 -0
- package/src/tools/patchFile.d.ts +4 -0
- package/src/tools/patchFile.mjs +133 -0
- package/src/tools/switchToMainAgent.d.ts +3 -0
- package/src/tools/switchToMainAgent.mjs +43 -0
- package/src/tools/switchToSubagent.d.ts +4 -0
- package/src/tools/switchToSubagent.mjs +59 -0
- package/src/tools/tmuxCommand.d.ts +14 -0
- package/src/tools/tmuxCommand.mjs +194 -0
- package/src/tools/writeFile.d.ts +4 -0
- package/src/tools/writeFile.mjs +56 -0
- package/src/usageStore.mjs +167 -0
- package/src/utils/evalJSONConfig.mjs +72 -0
- package/src/utils/matchValue.d.ts +6 -0
- package/src/utils/matchValue.mjs +40 -0
- package/src/utils/noThrow.mjs +31 -0
- package/src/utils/notify.mjs +29 -0
- package/src/utils/parseFileRange.mjs +18 -0
- package/src/utils/parseFrontmatter.mjs +19 -0
- package/src/utils/readFileRange.mjs +33 -0
- package/src/utils/retryOnError.mjs +41 -0
- package/src/voiceInput.mjs +61 -0
- package/src/voiceInputGemini.mjs +105 -0
- package/src/voiceInputOpenAI.mjs +104 -0
- package/src/voiceInputSession.mjs +543 -0
- package/src/voiceToggleKey.mjs +62 -0
- package/dist/main.mjs +0 -473
- package/dist/main.mjs.map +0 -7
package/src/main.mjs
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool } from "./tool";
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { styleText } from "node:util";
|
|
6
|
+
import { createAgent } from "./agent.mjs";
|
|
7
|
+
import {
|
|
8
|
+
installClaudeCodePlugins,
|
|
9
|
+
resolvePluginPaths,
|
|
10
|
+
} from "./claudeCodePlugin.mjs";
|
|
11
|
+
import { parseCliArgs, printHelp } from "./cliArgs.mjs";
|
|
12
|
+
import { startBatchSession } from "./cliBatch.mjs";
|
|
13
|
+
import { runCostCommand } from "./cliCost.mjs";
|
|
14
|
+
import { startInteractiveSession } from "./cliInteractive.mjs";
|
|
15
|
+
import { loadAppConfig } from "./config.mjs";
|
|
16
|
+
import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
|
|
17
|
+
import { loadPrompts } from "./context/loadPrompts.mjs";
|
|
18
|
+
import { AGENT_PROJECT_METADATA_DIR, USER_NAME } from "./env.mjs";
|
|
19
|
+
import { setupMCPServer } from "./mcpIntegration.mjs";
|
|
20
|
+
import { createModelCaller } from "./modelCaller.mjs";
|
|
21
|
+
import { createPrompt } from "./prompt.mjs";
|
|
22
|
+
import { createAskURLTool } from "./tools/askURL.mjs";
|
|
23
|
+
import { createAskWebTool } from "./tools/askWeb.mjs";
|
|
24
|
+
import { createCompactContextTool } from "./tools/compactContext.mjs";
|
|
25
|
+
import { createExecCommandTool } from "./tools/execCommand.mjs";
|
|
26
|
+
import { createPatchFileTool } from "./tools/patchFile.mjs";
|
|
27
|
+
import { createSwitchToMainAgentTool } from "./tools/switchToMainAgent.mjs";
|
|
28
|
+
import { createSwitchToSubagentTool } from "./tools/switchToSubagent.mjs";
|
|
29
|
+
import { createTmuxCommandTool } from "./tools/tmuxCommand.mjs";
|
|
30
|
+
import { writeFileTool } from "./tools/writeFile.mjs";
|
|
31
|
+
import { createToolUseApprover } from "./toolUseApprover.mjs";
|
|
32
|
+
|
|
33
|
+
const cliArgs = parseCliArgs(process.argv);
|
|
34
|
+
if (cliArgs.subcommand.type === "help") {
|
|
35
|
+
printHelp();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (cliArgs.subcommand.type === "list-models") {
|
|
39
|
+
const { appConfig } = await loadAppConfig({ skipTrustCheck: true });
|
|
40
|
+
if (!appConfig.models || appConfig.models.length === 0) {
|
|
41
|
+
console.error("No models found in configuration.");
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
for (const model of appConfig.models) {
|
|
45
|
+
const platform = model.platform;
|
|
46
|
+
console.log(
|
|
47
|
+
`${model.name}+${model.variant} (platform: ${platform.name}+${platform.variant})`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (cliArgs.subcommand.type === "install-claude-code-plugins") {
|
|
54
|
+
await installClaudeCodePlugins();
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (cliArgs.subcommand.type === "cost") {
|
|
59
|
+
try {
|
|
60
|
+
const exitCode = await runCostCommand({
|
|
61
|
+
from: cliArgs.subcommand.from,
|
|
62
|
+
to: cliArgs.subcommand.to,
|
|
63
|
+
});
|
|
64
|
+
process.exit(exitCode);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
67
|
+
console.error(message);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
(async () => {
|
|
73
|
+
const startTime = new Date();
|
|
74
|
+
const sessionId = [
|
|
75
|
+
`${startTime.getFullYear()}-${`0${startTime.getMonth() + 1}`.slice(-2)}-${`0${startTime.getDate()}`.slice(-2)}`,
|
|
76
|
+
`0${startTime.getHours()}`.slice(-2) +
|
|
77
|
+
`0${startTime.getMinutes()}`.slice(-2),
|
|
78
|
+
].join("-");
|
|
79
|
+
const tmuxSessionId = `agent-${sessionId}`;
|
|
80
|
+
|
|
81
|
+
const isBatchMode = cliArgs.subcommand.type === "batch";
|
|
82
|
+
const configFiles =
|
|
83
|
+
cliArgs.subcommand.type === "batch" ||
|
|
84
|
+
cliArgs.subcommand.type === "interactive"
|
|
85
|
+
? cliArgs.subcommand.config
|
|
86
|
+
: [];
|
|
87
|
+
|
|
88
|
+
const { appConfig, loadedConfigPath } = await loadAppConfig({
|
|
89
|
+
skipUserConfig: isBatchMode,
|
|
90
|
+
skipTrustCheck: isBatchMode,
|
|
91
|
+
configFiles,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// In batch mode, skip human-readable output
|
|
95
|
+
if (!isBatchMode) {
|
|
96
|
+
if (loadedConfigPath.length > 0) {
|
|
97
|
+
console.log(styleText("green", "\n⚡ Loaded configuration files"));
|
|
98
|
+
console.log(loadedConfigPath.map((p) => ` ⤷ ${p}`).join("\n"));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (appConfig.sandbox) {
|
|
102
|
+
const sandboxStr = [
|
|
103
|
+
appConfig.sandbox.command,
|
|
104
|
+
...(appConfig.sandbox.args || []),
|
|
105
|
+
].join(" ");
|
|
106
|
+
console.log(styleText("green", "\n📦 Sandbox: on"));
|
|
107
|
+
console.log(` ⤷ ${sandboxStr}`);
|
|
108
|
+
} else {
|
|
109
|
+
console.log(styleText("yellow", "\n📦 Sandbox: off"));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @type {(() => Promise<void>)[]} */
|
|
114
|
+
const mcpCleanups = [];
|
|
115
|
+
|
|
116
|
+
/** @type {Tool[]} */
|
|
117
|
+
const mcpTools = [];
|
|
118
|
+
if (appConfig.mcpServers) {
|
|
119
|
+
const mcpServerEntries = Object.entries(appConfig.mcpServers);
|
|
120
|
+
|
|
121
|
+
if (!isBatchMode) {
|
|
122
|
+
console.log();
|
|
123
|
+
for (const [serverName] of mcpServerEntries) {
|
|
124
|
+
console.log(
|
|
125
|
+
styleText("blue", `🔌 Connecting to MCP server: ${serverName}...`),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const mcpResults = await Promise.all(
|
|
131
|
+
mcpServerEntries.map(async ([serverName, serverConfig]) => {
|
|
132
|
+
const result = await setupMCPServer(serverName, serverConfig);
|
|
133
|
+
return { serverName, ...result };
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
for (const { serverName, tools, stderrLogPath, cleanup } of mcpResults) {
|
|
138
|
+
mcpTools.push(...tools);
|
|
139
|
+
mcpCleanups.push(cleanup);
|
|
140
|
+
if (!isBatchMode) {
|
|
141
|
+
console.log(
|
|
142
|
+
styleText(
|
|
143
|
+
"green",
|
|
144
|
+
`✅ Successfully connected to MCP server: ${serverName}`,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
console.log(` ⤷ stderr log: ${stderrLogPath}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const modelFromConfig = appConfig.model || "";
|
|
153
|
+
const modelFromArgs =
|
|
154
|
+
cliArgs.subcommand.type === "batch" ||
|
|
155
|
+
cliArgs.subcommand.type === "interactive"
|
|
156
|
+
? cliArgs.subcommand.model
|
|
157
|
+
: null;
|
|
158
|
+
const modelNameWithVariant = modelFromArgs || modelFromConfig;
|
|
159
|
+
|
|
160
|
+
const pluginPaths = resolvePluginPaths(appConfig.claudeCodePlugins ?? []);
|
|
161
|
+
const [prompts, agentRoles] = await Promise.all([
|
|
162
|
+
loadPrompts(pluginPaths),
|
|
163
|
+
loadAgentRoles(pluginPaths),
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const prompt = createPrompt({
|
|
167
|
+
username: USER_NAME,
|
|
168
|
+
modelName: modelNameWithVariant,
|
|
169
|
+
workingDir: process.cwd(),
|
|
170
|
+
today: new Date().toISOString().split("T")[0],
|
|
171
|
+
sessionId,
|
|
172
|
+
tmuxSessionId,
|
|
173
|
+
projectMetadataDir: AGENT_PROJECT_METADATA_DIR,
|
|
174
|
+
agentRoles,
|
|
175
|
+
skills: Array.from(prompts.values()).filter((p) => p.isSkill),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const builtinTools = [
|
|
179
|
+
createExecCommandTool({ sandbox: appConfig.sandbox }),
|
|
180
|
+
writeFileTool,
|
|
181
|
+
createPatchFileTool(),
|
|
182
|
+
createTmuxCommandTool({ sandbox: appConfig.sandbox }),
|
|
183
|
+
createCompactContextTool(),
|
|
184
|
+
createSwitchToSubagentTool(),
|
|
185
|
+
createSwitchToMainAgentTool(),
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
if (appConfig.tools?.askWeb) {
|
|
189
|
+
builtinTools.push(createAskWebTool(appConfig.tools.askWeb));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (appConfig.tools?.askURL) {
|
|
193
|
+
builtinTools.push(createAskURLTool(appConfig.tools.askURL));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const toolUseApprover = createToolUseApprover({
|
|
197
|
+
maxApprovals: appConfig.autoApproval?.maxApprovals || 50,
|
|
198
|
+
defaultAction: appConfig.autoApproval?.defaultAction || "ask",
|
|
199
|
+
patterns: appConfig.autoApproval?.patterns || [],
|
|
200
|
+
maskApprovalInput: (toolName, input) => {
|
|
201
|
+
for (const tool of builtinTools) {
|
|
202
|
+
if (tool.def.name === toolName && tool.maskApprovalInput) {
|
|
203
|
+
return tool.maskApprovalInput(input);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return input;
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const [modelName, modelVariant] = modelNameWithVariant.split("+");
|
|
211
|
+
const modelDef = (appConfig.models ?? []).find(
|
|
212
|
+
(entry) => entry.name === modelName && entry.variant === modelVariant,
|
|
213
|
+
);
|
|
214
|
+
if (!modelDef) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Model "${modelNameWithVariant}" not found in configuration.`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const platform = (appConfig.platforms ?? []).find(
|
|
221
|
+
(entry) =>
|
|
222
|
+
entry.name === modelDef.platform.name &&
|
|
223
|
+
entry.variant === modelDef.platform.variant,
|
|
224
|
+
);
|
|
225
|
+
if (!platform) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`Platform ${modelDef.platform.name} variant=${modelDef.platform.variant} not found in configuration.`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const { userEventEmitter, agentEventEmitter, agentCommands } = createAgent({
|
|
232
|
+
callModel: createModelCaller({
|
|
233
|
+
...modelDef,
|
|
234
|
+
platform: {
|
|
235
|
+
...modelDef.platform,
|
|
236
|
+
...platform,
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
prompt,
|
|
240
|
+
tools: [...builtinTools, ...mcpTools],
|
|
241
|
+
toolUseApprover,
|
|
242
|
+
agentRoles,
|
|
243
|
+
modelCostConfig: modelDef.cost,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const sessionOptions = {
|
|
247
|
+
userEventEmitter,
|
|
248
|
+
agentEventEmitter,
|
|
249
|
+
agentCommands,
|
|
250
|
+
sessionId,
|
|
251
|
+
modelName: modelNameWithVariant,
|
|
252
|
+
sandbox: Boolean(appConfig.sandbox),
|
|
253
|
+
startTime,
|
|
254
|
+
onStop: async () => {
|
|
255
|
+
for (const cleanup of mcpCleanups) {
|
|
256
|
+
await cleanup();
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
if (cliArgs.subcommand.type === "batch") {
|
|
262
|
+
const task = cliArgs.subcommand.task;
|
|
263
|
+
if (!task) {
|
|
264
|
+
throw new Error("Batch task is required in batch mode");
|
|
265
|
+
}
|
|
266
|
+
await startBatchSession({
|
|
267
|
+
...sessionOptions,
|
|
268
|
+
task,
|
|
269
|
+
});
|
|
270
|
+
} else {
|
|
271
|
+
startInteractiveSession({
|
|
272
|
+
...sessionOptions,
|
|
273
|
+
notifyCmd: appConfig.notifyCmd,
|
|
274
|
+
claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
|
|
275
|
+
voiceInput: appConfig.voiceInput,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
})().catch((err) => {
|
|
279
|
+
console.error(err);
|
|
280
|
+
process.exit(1);
|
|
281
|
+
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, openSync } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} CreateMCPClientOptions
|
|
7
|
+
* @property {string} command
|
|
8
|
+
* @property {string[]} [args]
|
|
9
|
+
* @property {Record<string, string>} [env]
|
|
10
|
+
* @property {"inherit" | "ignore" | "pipe" | string} [stderr]
|
|
11
|
+
* @property {string} [protocolVersion]
|
|
12
|
+
* @property {{ name: string, version: string }} [clientInfo]
|
|
13
|
+
* @property {Record<string, unknown>} [capabilities]
|
|
14
|
+
* @property {(method: string, params?: unknown) => void} [onNotification]
|
|
15
|
+
* Currently unused by callers, but provided for MCP protocol compliance (e.g. notifications/progress).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Spawn an MCP server process and return an initialized client.
|
|
20
|
+
* @param {CreateMCPClientOptions} options
|
|
21
|
+
* @returns {Promise<MCPClient>}
|
|
22
|
+
*/
|
|
23
|
+
export async function createMCPClient(options) {
|
|
24
|
+
const transport = new StdioTransport(options.command, options.args, {
|
|
25
|
+
env: options.env,
|
|
26
|
+
stderr: options.stderr,
|
|
27
|
+
onNotification: options.onNotification,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const client = new MCPClient(transport);
|
|
31
|
+
try {
|
|
32
|
+
await client.initialize({
|
|
33
|
+
protocolVersion: options.protocolVersion,
|
|
34
|
+
clientInfo: options.clientInfo,
|
|
35
|
+
capabilities: options.capabilities,
|
|
36
|
+
});
|
|
37
|
+
} catch (err) {
|
|
38
|
+
await client.close().catch(() => {});
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
return client;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* MCP protocol client.
|
|
46
|
+
* Delegates transport concerns to a transport object.
|
|
47
|
+
*/
|
|
48
|
+
export class MCPClient {
|
|
49
|
+
/** @type {StdioTransport} */
|
|
50
|
+
#transport;
|
|
51
|
+
#closed = false;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {StdioTransport} transport
|
|
55
|
+
*/
|
|
56
|
+
constructor(transport) {
|
|
57
|
+
this.#transport = transport;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {Object} [options]
|
|
62
|
+
* @param {string} [options.protocolVersion]
|
|
63
|
+
* @param {{ name: string, version: string }} [options.clientInfo]
|
|
64
|
+
* @param {Record<string, unknown>} [options.capabilities]
|
|
65
|
+
* @returns {Promise<any>}
|
|
66
|
+
*/
|
|
67
|
+
async initialize(options = {}) {
|
|
68
|
+
if (this.#closed) {
|
|
69
|
+
throw new Error("MCP client is closed");
|
|
70
|
+
}
|
|
71
|
+
const result = await this.#transport.request("initialize", {
|
|
72
|
+
protocolVersion: options.protocolVersion ?? "2025-03-26",
|
|
73
|
+
capabilities: options.capabilities ?? {},
|
|
74
|
+
clientInfo: options.clientInfo ?? {
|
|
75
|
+
name: "unknown",
|
|
76
|
+
version: "0.0.0",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
this.#transport.notify("notifications/initialized");
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @returns {Promise<{ tools: Array<{ name: string, description?: string, inputSchema: Record<string, unknown> }> }>}
|
|
85
|
+
*/
|
|
86
|
+
async listTools() {
|
|
87
|
+
if (this.#closed) {
|
|
88
|
+
throw new Error("MCP client is closed");
|
|
89
|
+
}
|
|
90
|
+
return this.#transport.request("tools/list", {});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {{ name: string, arguments?: Record<string, unknown> }} params
|
|
95
|
+
* @returns {Promise<{ content?: Array<{ type: string, text?: string, data?: string, mimeType?: string }>, isError?: boolean }>}
|
|
96
|
+
*/
|
|
97
|
+
async callTool(params) {
|
|
98
|
+
if (this.#closed) {
|
|
99
|
+
throw new Error("MCP client is closed");
|
|
100
|
+
}
|
|
101
|
+
return this.#transport.request("tools/call", params);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async close() {
|
|
105
|
+
if (this.#closed) return;
|
|
106
|
+
this.#closed = true;
|
|
107
|
+
await this.#transport.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {Object} StdioTransportOptions
|
|
113
|
+
* @property {Record<string, string>} [env]
|
|
114
|
+
* @property {"inherit" | "ignore" | "pipe" | string} [stderr]
|
|
115
|
+
* @property {(method: string, params?: unknown) => void} [onNotification]
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* JSON-RPC 2.0 transport over stdio.
|
|
120
|
+
* Manages the child process lifecycle and message passing.
|
|
121
|
+
*/
|
|
122
|
+
export class StdioTransport {
|
|
123
|
+
/** @type {import("node:child_process").ChildProcess} */
|
|
124
|
+
#process;
|
|
125
|
+
/** @type {import("node:readline").Interface} */
|
|
126
|
+
#rl;
|
|
127
|
+
#nextId = 1;
|
|
128
|
+
/** @type {Map<number, { resolve: (value: any) => void, reject: (reason: any) => void, timer: NodeJS.Timeout }>} */
|
|
129
|
+
#pendingRequests = new Map();
|
|
130
|
+
#closed = false;
|
|
131
|
+
/** @type {Error | undefined} */
|
|
132
|
+
#earlyExitError;
|
|
133
|
+
/** @type {((line: string) => void)} */
|
|
134
|
+
#onLine;
|
|
135
|
+
/** @type {((code: number | null) => void)} */
|
|
136
|
+
#onClose;
|
|
137
|
+
/** @type {((err: Error) => void)} */
|
|
138
|
+
#onError;
|
|
139
|
+
/** @type {number | undefined} */
|
|
140
|
+
#stderrFd;
|
|
141
|
+
/** @type {((method: string, params?: unknown) => void) | undefined} */
|
|
142
|
+
// Currently unused by callers, but provided for MCP protocol compliance (e.g. notifications/progress).
|
|
143
|
+
#onNotification;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} command
|
|
147
|
+
* @param {string[]} [args]
|
|
148
|
+
* @param {StdioTransportOptions} [options]
|
|
149
|
+
*/
|
|
150
|
+
constructor(command, args, options = {}) {
|
|
151
|
+
const defaultEnv = {
|
|
152
|
+
PWD: process.env.PWD || "",
|
|
153
|
+
PATH: process.env.PATH || "",
|
|
154
|
+
HOME: process.env.HOME || "",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** @type {"inherit" | "ignore" | "pipe" | number} */
|
|
158
|
+
let stderrValue = "ignore";
|
|
159
|
+
if (
|
|
160
|
+
options.stderr === "inherit" ||
|
|
161
|
+
options.stderr === "ignore" ||
|
|
162
|
+
options.stderr === "pipe"
|
|
163
|
+
) {
|
|
164
|
+
stderrValue = options.stderr;
|
|
165
|
+
} else if (typeof options.stderr === "string") {
|
|
166
|
+
this.#stderrFd = openSync(options.stderr, "a");
|
|
167
|
+
stderrValue = this.#stderrFd;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const childProcess = spawn(command, args || [], {
|
|
171
|
+
env: { ...defaultEnv, ...options.env },
|
|
172
|
+
stdio: /** @type {import("node:child_process").StdioOptions} */ ([
|
|
173
|
+
"pipe",
|
|
174
|
+
"pipe",
|
|
175
|
+
stderrValue,
|
|
176
|
+
]),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
this.#process = childProcess;
|
|
180
|
+
this.#onNotification = options.onNotification;
|
|
181
|
+
|
|
182
|
+
if (!childProcess.stdout) {
|
|
183
|
+
throw new Error("MCP server stdout is not available");
|
|
184
|
+
}
|
|
185
|
+
this.#rl = createInterface({ input: childProcess.stdout });
|
|
186
|
+
|
|
187
|
+
this.#onLine = (line) => this.#handleLine(line);
|
|
188
|
+
this.#rl.on("line", this.#onLine);
|
|
189
|
+
|
|
190
|
+
this.#onClose = (code) => this.#handleProcessClose(code);
|
|
191
|
+
childProcess.on("close", this.#onClose);
|
|
192
|
+
|
|
193
|
+
this.#onError = (err) => this.#handleProcessError(err);
|
|
194
|
+
childProcess.on("error", this.#onError);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @returns {import("node:child_process").ChildProcess}
|
|
199
|
+
*/
|
|
200
|
+
get process() {
|
|
201
|
+
return this.#process;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {string} line
|
|
206
|
+
*/
|
|
207
|
+
#handleLine(line) {
|
|
208
|
+
if (!line.trim()) return;
|
|
209
|
+
try {
|
|
210
|
+
const msg = JSON.parse(line);
|
|
211
|
+
if (!("id" in msg)) {
|
|
212
|
+
this.#onNotification?.(msg.method, msg.params);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (this.#pendingRequests.has(msg.id)) {
|
|
216
|
+
const pending = this.#pendingRequests.get(msg.id);
|
|
217
|
+
if (!pending) return;
|
|
218
|
+
this.#pendingRequests.delete(msg.id);
|
|
219
|
+
clearTimeout(pending.timer);
|
|
220
|
+
if (msg.error) {
|
|
221
|
+
pending.reject(
|
|
222
|
+
new Error(msg.error.message || JSON.stringify(msg.error)),
|
|
223
|
+
);
|
|
224
|
+
} else {
|
|
225
|
+
pending.resolve(msg.result);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Ignore non-JSON lines
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @param {number | null} code
|
|
235
|
+
*/
|
|
236
|
+
#handleProcessClose(code) {
|
|
237
|
+
const err = new Error(`MCP server exited with code ${code}`);
|
|
238
|
+
this.#earlyExitError = err;
|
|
239
|
+
this.#rejectAllPending(err);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {Error} err
|
|
244
|
+
*/
|
|
245
|
+
#handleProcessError(err) {
|
|
246
|
+
this.#earlyExitError = err;
|
|
247
|
+
this.#rejectAllPending(err);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {Error} error
|
|
252
|
+
*/
|
|
253
|
+
#rejectAllPending(error) {
|
|
254
|
+
for (const [, { reject, timer }] of this.#pendingRequests) {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
reject(error);
|
|
257
|
+
}
|
|
258
|
+
this.#pendingRequests.clear();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @param {string} method
|
|
263
|
+
* @param {Record<string, unknown>} [params]
|
|
264
|
+
* @param {number} [timeoutMs]
|
|
265
|
+
* @returns {Promise<any>}
|
|
266
|
+
*/
|
|
267
|
+
request(method, params, timeoutMs = 30000) {
|
|
268
|
+
if (this.#closed) {
|
|
269
|
+
return Promise.reject(new Error("MCP client is closed"));
|
|
270
|
+
}
|
|
271
|
+
if (this.#earlyExitError) {
|
|
272
|
+
return Promise.reject(this.#earlyExitError);
|
|
273
|
+
}
|
|
274
|
+
const id = this.#nextId++;
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
const timer = setTimeout(() => {
|
|
277
|
+
this.#pendingRequests.delete(id);
|
|
278
|
+
reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`));
|
|
279
|
+
}, timeoutMs);
|
|
280
|
+
|
|
281
|
+
this.#pendingRequests.set(id, { resolve, reject, timer });
|
|
282
|
+
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
283
|
+
this.#process.stdin?.write(`${msg}\n`, (err) => {
|
|
284
|
+
if (err) {
|
|
285
|
+
clearTimeout(timer);
|
|
286
|
+
this.#pendingRequests.delete(id);
|
|
287
|
+
reject(err);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {string} method
|
|
295
|
+
* @param {Record<string, unknown>} [params]
|
|
296
|
+
*/
|
|
297
|
+
notify(method, params) {
|
|
298
|
+
if (this.#closed || this.#earlyExitError) return;
|
|
299
|
+
const msg = JSON.stringify(
|
|
300
|
+
params ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", method },
|
|
301
|
+
);
|
|
302
|
+
this.#process.stdin?.write(`${msg}\n`, () => {
|
|
303
|
+
// Ignore write errors in notifications
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @param {NodeJS.Signals} [signal]
|
|
309
|
+
* @param {number} [timeoutMs]
|
|
310
|
+
* @returns {Promise<void>}
|
|
311
|
+
*/
|
|
312
|
+
async close(signal = "SIGTERM", timeoutMs = 5000) {
|
|
313
|
+
if (this.#closed) return;
|
|
314
|
+
this.#closed = true;
|
|
315
|
+
this.#rejectAllPending(new Error("MCP client is closed"));
|
|
316
|
+
this.#rl.off("line", this.#onLine);
|
|
317
|
+
this.#rl.close();
|
|
318
|
+
this.#process.stdin?.end();
|
|
319
|
+
this.#process.off("close", this.#onClose);
|
|
320
|
+
this.#process.off("error", this.#onError);
|
|
321
|
+
|
|
322
|
+
const closePromise = new Promise((resolve) => {
|
|
323
|
+
if (
|
|
324
|
+
this.#process.exitCode !== null ||
|
|
325
|
+
this.#process.signalCode !== null
|
|
326
|
+
) {
|
|
327
|
+
resolve(undefined);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const timer = setTimeout(() => {
|
|
331
|
+
this.#process.kill("SIGKILL");
|
|
332
|
+
resolve(undefined);
|
|
333
|
+
}, timeoutMs);
|
|
334
|
+
this.#process.once("close", () => {
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
resolve(undefined);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
this.#process.kill(signal);
|
|
341
|
+
await closePromise;
|
|
342
|
+
|
|
343
|
+
if (this.#stderrFd !== undefined) {
|
|
344
|
+
try {
|
|
345
|
+
closeSync(this.#stderrFd);
|
|
346
|
+
} catch {
|
|
347
|
+
// ignore
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|