mcp-agents 0.3.1

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.
Files changed (3) hide show
  1. package/README.md +90 -0
  2. package/package.json +36 -0
  3. package/server.js +339 -0
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # mcp-agents
2
+
3
+ MCP server that wraps AI CLI tools — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Gemini CLI](https://github.com/google-gemini/gemini-cli), and [Codex CLI](https://github.com/openai/codex) — so any MCP client can call them as tools.
4
+
5
+ ## Prerequisites
6
+
7
+ - **Node.js >= 18**
8
+ - At least one of the following CLIs installed and on your `$PATH`:
9
+
10
+ | CLI | Install |
11
+ |-----|---------|
12
+ | `claude` | [Claude Code docs](https://docs.anthropic.com/en/docs/claude-code) |
13
+ | `gemini` | `npm install -g @anthropic-ai/gemini-cli` |
14
+ | `codex` | `npm install -g @openai/codex` |
15
+
16
+ Only the CLI you select with `--provider` needs to be present.
17
+
18
+ ## Quick test
19
+
20
+ ```bash
21
+ # Default provider (codex)
22
+ npx mcp-agents
23
+
24
+ # Specific provider
25
+ npx mcp-agents --provider claude
26
+ npx mcp-agents --provider gemini
27
+ ```
28
+
29
+ The server speaks [JSON-RPC over stdio](https://modelcontextprotocol.io/docs/concepts/transports#stdio). It prints `[mcp-agents] ready (provider: <name>)` to stderr when it's listening.
30
+
31
+ ## Providers & Tools
32
+
33
+ Each `--provider` flag maps to a single exposed tool:
34
+
35
+ | Provider | Tool name | CLI command |
36
+ |----------|-----------|-------------|
37
+ | `claude` | `claude_code` | `claude -p <prompt>` |
38
+ | `gemini` | `gemini` | `gemini [-s] -p <prompt>` |
39
+
40
+ ### `claude_code` parameters
41
+
42
+ | Parameter | Type | Required | Description |
43
+ |-----------|------|----------|-------------|
44
+ | `prompt` | `string` | yes | The prompt to send to Claude Code |
45
+ | `timeout_ms` | `integer` | no | Timeout in ms (default: 120 000) |
46
+
47
+ ### `gemini` parameters
48
+
49
+ | Parameter | Type | Required | Description |
50
+ |-----------|------|----------|-------------|
51
+ | `prompt` | `string` | yes | The prompt to send to Gemini CLI |
52
+ | `sandbox` | `boolean` | no | Run in sandbox mode (`-s` flag) |
53
+ | `timeout_ms` | `integer` | no | Timeout in ms (default: 120 000) |
54
+
55
+ ### `codex` parameters
56
+
57
+ | Parameter | Type | Required | Description |
58
+ |-----------|------|----------|-------------|
59
+ | `prompt` | `string` | yes | The prompt to send to Codex CLI |
60
+ | `timeout_ms` | `integer` | no | Timeout in ms (default: 120 000) |
61
+
62
+ ## Integration with OpenAI Codex
63
+
64
+ Add two entries to `~/.codex/config.toml` — one per provider you want available:
65
+
66
+ ```toml
67
+ [mcp_servers.claude-code]
68
+ command = "npx"
69
+ args = ["-y", "mcp-agents", "--provider", "claude"]
70
+
71
+ [mcp_servers.gemini]
72
+ command = "npx"
73
+ args = ["-y", "mcp-agents", "--provider", "gemini"]
74
+ ```
75
+
76
+ Then in a Codex session you can call the `claude_code` or `gemini` tools, which shell out to the respective CLIs.
77
+
78
+ ## How it works
79
+
80
+ 1. An MCP client connects over stdio
81
+ 2. The server reads `--provider <name>` from its argv (defaults to `codex`)
82
+ 3. It registers a single tool matching that provider's CLI
83
+ 4. Client calls `tools/call` with the tool name and a `prompt`
84
+ 5. The server runs the CLI as a child process and returns stdout (or stderr) as the tool result
85
+
86
+ The server includes a keepalive timer to prevent Node.js from exiting prematurely when stdin reaches EOF before the async subprocess registers an active handle.
87
+
88
+ ## License
89
+
90
+ MIT
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "mcp-agents",
3
+ "version": "0.3.1",
4
+ "description": "MCP server that wraps AI CLI tools (Claude Code, Gemini CLI, Codex CLI) for use by any MCP client",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcp-agents": "./server.js"
8
+ },
9
+ "files": [
10
+ "server.js",
11
+ "package.json"
12
+ ],
13
+ "keywords": [
14
+ "mcp",
15
+ "claude",
16
+ "gemini",
17
+ "codex",
18
+ "anthropic",
19
+ "google",
20
+ "openai",
21
+ "ai",
22
+ "cli"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/thomaswitt/mcp-agents"
27
+ },
28
+ "author": "Thomas Witt",
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.0.0"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "license": "MIT"
36
+ }
package/server.js ADDED
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+
4
+ import { execFile } from "node:child_process";
5
+ import { readFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import {
12
+ CallToolRequestSchema,
13
+ ListToolsRequestSchema,
14
+ } from "@modelcontextprotocol/sdk/types.js";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const VERSION = JSON.parse(
18
+ readFileSync(join(__dirname, "package.json"), "utf8"),
19
+ ).version;
20
+
21
+ const DEFAULT_TIMEOUT_MS = 120_000;
22
+ const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // CLI Backend Definitions
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const CLI_BACKENDS = {
29
+ claude: {
30
+ command: "claude",
31
+ toolName: "claude_code",
32
+ description: "Run Claude Code CLI (claude -p) with a prompt.",
33
+ buildArgs: (prompt) => ["-p", prompt],
34
+ extraProperties: {},
35
+ },
36
+ gemini: {
37
+ command: "gemini",
38
+ toolName: "gemini",
39
+ description: "Run Gemini CLI (gemini -p) with a prompt.",
40
+ buildArgs: (prompt, opts) => {
41
+ const args = [];
42
+ if (opts.sandbox !== false) args.push("-s");
43
+ args.push("-p", prompt);
44
+ return args;
45
+ },
46
+ extraProperties: {
47
+ sandbox: {
48
+ type: "boolean",
49
+ default: true,
50
+ description: "Run in sandbox mode (-s flag). Defaults to true.",
51
+ },
52
+ },
53
+ },
54
+ codex: {
55
+ command: "codex",
56
+ toolName: "codex",
57
+ description: "Run Codex CLI (codex exec) with a prompt.",
58
+ buildArgs: (prompt) => ["exec", prompt],
59
+ extraProperties: {},
60
+ },
61
+ };
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Helpers
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Never write debug logs to stdout (it breaks MCP stdio transport).
69
+ * Use stderr only.
70
+ */
71
+ function logErr(message) {
72
+ process.stderr.write(`${message}\n`);
73
+ }
74
+
75
+ /**
76
+ * Defensive string conversion for tool args.
77
+ * @param {unknown} value
78
+ * @returns {string}
79
+ */
80
+ function toStringArg(value) {
81
+ if (typeof value === "string") return value;
82
+ if (value == null) return "";
83
+ return String(value);
84
+ }
85
+
86
+ /**
87
+ * Print usage information to stdout.
88
+ */
89
+ function printHelp() {
90
+ const providers = Object.keys(CLI_BACKENDS).join(", ");
91
+ console.log(`mcp-agents v${VERSION}
92
+
93
+ Usage: mcp-agents [options]
94
+
95
+ Options:
96
+ --provider <name> CLI backend to use (${providers}) [default: codex]
97
+ --help, -h Show this help message
98
+ --version, -v Show version number`);
99
+ }
100
+
101
+ /**
102
+ * Parse CLI flags from process.argv.
103
+ * Handles --help, --version, --provider, and unknown flags.
104
+ * @returns {string | null} Provider name, or null if the process should exit.
105
+ */
106
+ function parseArgs() {
107
+ const args = process.argv.slice(2);
108
+ let provider = "codex";
109
+
110
+ for (let i = 0; i < args.length; i++) {
111
+ switch (args[i]) {
112
+ case "--help":
113
+ case "-h":
114
+ printHelp();
115
+ process.exit(0);
116
+ break;
117
+ case "--version":
118
+ case "-v":
119
+ console.log(`mcp-agents v${VERSION}`);
120
+ process.exit(0);
121
+ break;
122
+ case "--provider":
123
+ if (i + 1 >= args.length) {
124
+ process.stderr.write("error: --provider requires a value\n");
125
+ process.exit(1);
126
+ }
127
+ provider = args[++i];
128
+ break;
129
+ default:
130
+ process.stderr.write(`error: unknown option: ${args[i]}\n`);
131
+ process.exit(1);
132
+ }
133
+ }
134
+
135
+ return provider;
136
+ }
137
+
138
+ /**
139
+ * Run a CLI command and return stdout (or stderr if stdout is empty).
140
+ * @param {string} command
141
+ * @param {string[]} args
142
+ * @param {{ timeoutMs?: number }} [opts]
143
+ * @returns {Promise<string>}
144
+ */
145
+ function runCli(command, args, opts = {}) {
146
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
147
+
148
+ return new Promise((resolve, reject) => {
149
+ const child = execFile(
150
+ command,
151
+ args,
152
+ {
153
+ timeout: timeoutMs,
154
+ maxBuffer: MAX_BUFFER_BYTES,
155
+ env: { ...process.env, NO_COLOR: "1" },
156
+ },
157
+ (error, stdout, stderr) => {
158
+ if (error) {
159
+ const details = [
160
+ `${command} failed: ${error.message}`,
161
+ stderr ? `stderr:\n${stderr}` : null,
162
+ ]
163
+ .filter(Boolean)
164
+ .join("\n");
165
+
166
+ reject(new Error(details));
167
+ return;
168
+ }
169
+
170
+ const out = (stdout || stderr || "").trimEnd();
171
+ resolve(out);
172
+ },
173
+ );
174
+
175
+ // Close stdin immediately so the child process doesn't wait for piped input.
176
+ // execFile creates a pipe for stdin by default; leaving it open causes
177
+ // the child to hang indefinitely waiting for EOF.
178
+ child.stdin?.end();
179
+
180
+ child.on("error", (err) => {
181
+ reject(new Error(`Failed to start ${command}: ${err.message}`));
182
+ });
183
+ });
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Main
188
+ // ---------------------------------------------------------------------------
189
+
190
+ async function main() {
191
+ const providerName = parseArgs();
192
+ const backend = CLI_BACKENDS[providerName];
193
+
194
+ if (!backend) {
195
+ logErr(`[mcp-agents] Unknown provider: ${providerName}`);
196
+ logErr(
197
+ `[mcp-agents] Available: ${Object.keys(CLI_BACKENDS).join(", ")}`,
198
+ );
199
+ process.exitCode = 1;
200
+ return;
201
+ }
202
+
203
+ const server = new Server(
204
+ { name: "mcp-agents", version: VERSION },
205
+ { capabilities: { tools: {} } },
206
+ );
207
+
208
+ const properties = {
209
+ prompt: {
210
+ type: "string",
211
+ description: `Prompt for ${backend.command}`,
212
+ },
213
+ timeout_ms: {
214
+ type: "integer",
215
+ minimum: 1,
216
+ description: `Optional timeout override (default ${DEFAULT_TIMEOUT_MS})`,
217
+ },
218
+ ...backend.extraProperties,
219
+ };
220
+
221
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
222
+ tools: [
223
+ {
224
+ name: "ping",
225
+ description:
226
+ "Connectivity test. Returns 'pong' instantly without calling the CLI.",
227
+ inputSchema: {
228
+ type: "object",
229
+ additionalProperties: false,
230
+ properties: {},
231
+ },
232
+ },
233
+ {
234
+ name: backend.toolName,
235
+ description: backend.description,
236
+ inputSchema: {
237
+ type: "object",
238
+ additionalProperties: false,
239
+ properties,
240
+ required: ["prompt"],
241
+ },
242
+ },
243
+ ],
244
+ }));
245
+
246
+ server.setRequestHandler(CallToolRequestSchema, async ({ params }) => {
247
+ if (params.name === "ping") {
248
+ return { content: [{ type: "text", text: "pong" }] };
249
+ }
250
+
251
+ if (params.name !== backend.toolName) {
252
+ return {
253
+ content: [
254
+ {
255
+ type: "text",
256
+ text: `Unknown tool: ${params.name}`,
257
+ },
258
+ ],
259
+ isError: true,
260
+ };
261
+ }
262
+
263
+ const prompt = toStringArg(params.arguments?.prompt);
264
+ const timeoutMsRaw = params.arguments?.timeout_ms;
265
+ const timeoutMs = Number.isInteger(timeoutMsRaw)
266
+ ? timeoutMsRaw
267
+ : DEFAULT_TIMEOUT_MS;
268
+
269
+ if (!prompt.trim()) {
270
+ return {
271
+ content: [
272
+ {
273
+ type: "text",
274
+ text: "Missing required argument: prompt",
275
+ },
276
+ ],
277
+ isError: true,
278
+ };
279
+ }
280
+
281
+ const extraOpts = {};
282
+ for (const key of Object.keys(backend.extraProperties)) {
283
+ if (params.arguments?.[key] != null) {
284
+ extraOpts[key] = params.arguments[key];
285
+ }
286
+ }
287
+
288
+ const cliArgs = backend.buildArgs(prompt, extraOpts);
289
+
290
+ logErr(`[mcp-agents] tools/call: running ${backend.command} …`);
291
+ try {
292
+ const output = await runCli(backend.command, cliArgs, { timeoutMs });
293
+ logErr("[mcp-agents] tools/call: done");
294
+ return {
295
+ content: [{ type: "text", text: output || "" }],
296
+ };
297
+ } catch (err) {
298
+ const msg = err instanceof Error ? err.message : String(err);
299
+ logErr(msg);
300
+ return {
301
+ content: [{ type: "text", text: msg }],
302
+ isError: true,
303
+ };
304
+ }
305
+ });
306
+
307
+ const transport = new StdioServerTransport();
308
+ await server.connect(transport);
309
+
310
+ // Prevent premature exit when stdin EOF arrives before async
311
+ // request handlers (tools/call -> execFile) register active handles.
312
+ // The SDK transport doesn't listen for stdin 'end', so the event
313
+ // loop loses its only handle when the pipe closes.
314
+ const keepAlive = setInterval(() => {}, 60_000);
315
+ const origOnClose = transport.onclose;
316
+ transport.onclose = () => {
317
+ clearInterval(keepAlive);
318
+ origOnClose?.();
319
+ };
320
+
321
+ logErr(`[mcp-agents] ready (provider: ${providerName})`);
322
+ }
323
+
324
+ process.on("unhandledRejection", (reason) => {
325
+ logErr(
326
+ `UnhandledRejection: ${reason instanceof Error ? reason.stack : reason}`,
327
+ );
328
+ process.exitCode = 1;
329
+ });
330
+
331
+ process.on("uncaughtException", (err) => {
332
+ logErr(`UncaughtException: ${err.stack || err.message}`);
333
+ process.exitCode = 1;
334
+ });
335
+
336
+ main().catch((err) => {
337
+ logErr(err.stack || err.message);
338
+ process.exitCode = 1;
339
+ });