# Directory Structure
```
├── .gitignore
├── package.json
├── README.md
├── src
│ └── index.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Dependencies
2 | node_modules/
3 | package-lock.json
4 |
5 | # Build output
6 | build/
7 | dist/
8 | *.js
9 | *.js.map
10 | *.d.ts
11 | *.d.ts.map
12 |
13 | # IDEs and editors
14 | .idea/
15 | .vscode/
16 | *.swp
17 | *.swo
18 | .DS_Store
19 | docs/
20 | memory-bank/
21 |
22 | # Logs
23 | logs/
24 | *.log
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 |
29 | # Environment variables
30 | .env
31 | .env.*
32 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 |
2 | <div align="center">
3 |
4 | # MCP BatchIt
5 |
6 | **Batch multiple MCP tool calls into a single "batch_execute" request—reducing overhead and token usage for AI agents.**
7 |
8 | [](https://opensource.org/licenses/MIT)
9 |
10 | </div>
11 |
12 | ---
13 |
14 | ## Table of Contents
15 |
16 | 1. [Introduction](#introduction)
17 | 2. [Why Use BatchIt](#why-use-batchit)
18 | 3. [Key Features & Limitations](#key-features--limitations)
19 | 4. [Installation & Startup](#installation--startup)
20 | 5. [Multi-Phase Usage](#multi-phase-usage)
21 | - [Implementation Phases](#implementation-phases)
22 | - [Information Gathering](#information-gathering)
23 | - [LLM‐Only Step (List Code Definitions)](#llm-only-step-list-code-definitions)
24 | - [Document Creation](#document-creation)
25 | 6. [FAQ](#faq)
26 | 7. [License](#license)
27 |
28 | ---
29 |
30 | ## Introduction
31 | > ⚠️ **NOTICE: Work in Progress**
32 | >
33 | > This project is actively being developed to address several complex challenges:
34 | > - Maintaining backwards compatibility with existing MCP servers
35 | > - Resolving transport complexities with multi-connection clients (Cline, Roo, Claude Desktop)
36 | > - Creating a beginner-friendly implementation
37 | >
38 | > While functional, expect ongoing improvements and changes as we refine the solution.
39 |
40 | **MCP BatchIt** is a simple aggregator server in the [Model Context Protocol (MCP)](https://modelcontext.ai/) ecosystem. It exposes just **one** tool: **`batch_execute`**. Rather than calling multiple MCP tools (like `fetch`, `read_file`, `create_directory`, `write_file`, etc.) in **separate** messages, you can **batch** them together in one aggregator request.
41 |
42 | This dramatically reduces token usage, network overhead, and repeated context in your AI agent or LLM conversation.
43 |
44 | ---
45 |
46 | ## Why Use BatchIt
47 |
48 | - **One Action per Message** Problem:
49 | Normally, an LLM or AI agent can only call a single MCP tool at a time, forcing multiple calls for multi-step tasks.
50 |
51 | - **Excessive Round Trips**:
52 | 10 separate file operations might require 10 messages → 10 responses.
53 |
54 | - **BatchIt’s Approach**:
55 | 1. Takes a single `batch_execute` request.
56 | 2. Spawns (or connects to) the actual target MCP server (like a filesystem server) behind the scenes.
57 | 3. Runs each sub-operation (tool call) in parallel up to `maxConcurrent`.
58 | 4. If one sub-op fails and `stopOnError` is true, it halts new sub-ops.
59 | 5. Returns one consolidated JSON result.
60 |
61 | ---
62 |
63 | ## Key Features & Limitations
64 |
65 | ### Features
66 |
67 | 1. **Single “Batch Execute” Tool**
68 | - You simply specify a list of sub‐ops referencing your existing MCP server’s tools.
69 |
70 | 2. **Parallel Execution**
71 | - Run multiple sub-ops at once, controlled by `maxConcurrent`.
72 |
73 | 3. **Timeout & Stop on Error**
74 | - Each sub-op races a `timeoutMs`, and you can skip remaining ops if one fails.
75 |
76 | 4. **Connection Caching**
77 | - Reuses the same connection to the downstream MCP server for repeated calls, closing after an idle timeout.
78 |
79 | ### Limitations
80 |
81 | 1. **No Data Passing Mid-Batch**
82 | - If sub-op #2 depends on #1’s output, do multiple aggregator calls.
83 | 2. **No Partial Progress**
84 | - You get all sub-ops’ results together at the end of each “batch_execute.”
85 | 3. **Must Use a Real MCP Server**
86 | - If you spawn or connect to the aggregator itself, you’ll see “tool not found.” The aggregator only has “batch_execute.”
87 | 4. **One Target Server per Call**
88 | - Each aggregator call references a single target MCP server. If you want multiple servers, you’d do more advanced logic or separate calls.
89 |
90 | ---
91 |
92 | ## Installation & Startup
93 |
94 | ```bash
95 | git clone https://github.com/ryanjoachim/mcp-batchit.git
96 | cd mcp-batchit
97 | npm install
98 | npm run build
99 | npm start
100 | ```
101 |
102 | BatchIt starts on **STDIO** by default so your AI agent (or any MCP client) can spawn it. For example:
103 |
104 | ```
105 | mcp-batchit is running on stdio. Ready to batch-execute!
106 | ```
107 |
108 | You can now send JSON-RPC requests (`tools/call` method, `name= "batch_execute"`) to it.
109 |
110 | ---
111 |
112 | ## MEMORY BANK
113 |
114 | Using Cline/Roo Code, you can build a framework of contextual project documentation by leveraging the powerful "Memory Bank" custom instructions developed by Nick Baumann.
115 |
116 | [View Memory Bank Documentation](https://github.com/nickbaumann98/cline_docs/blob/main/prompting/custom%20instructions%20library/cline-memory-bank.md)
117 |
118 | #### Traditional Approach (19+ calls):
119 |
120 | 1. Read package.json
121 | 2. Wait for response
122 | 3. Read README.md
123 | 4. Wait for response
124 | 5. List code definitions
125 | 6. Wait for response
126 | 7. Create memory-bank directory
127 | 8. Wait for response
128 | 9. Write productContext.md
129 | 10. Write systemPatterns.md
130 | 11. Write techContext.md
131 | 12. Write progress.md
132 | 13. Write activeContext.md
133 | 14. Wait for responses (5 more calls)
134 |
135 | Total: ~19 separate API calls (13 operations + 6 response waits)
136 |
137 | #### BatchIt Approach (1-3 calls)
138 |
139 | ### Multi-Phase Usage
140 |
141 | When working with complex multi-step tasks that depend on real-time output (such as reading files and generating documentation), you'll need to handle the process in distinct phases. This is necessary because **BatchIt** doesn't support data passing between sub-operations within the same request.
142 |
143 | ### Implementation Phases
144 |
145 | #### Information Gathering
146 |
147 | In this initial phase, we gather information from the filesystem by reading necessary files (e.g., `package.json`, `README.md`). This is accomplished through a **batch_execute** call to the filesystem MCP server:
148 |
149 | ```jsonc
150 | {
151 | "targetServer": {
152 | "name": "filesystem",
153 | "serverType": {
154 | "type": "filesystem",
155 | "config": {
156 | "rootDirectory": "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit"
157 | }
158 | },
159 | "transport": {
160 | "type": "stdio",
161 | "command": "cmd.exe",
162 | "args": [
163 | "/c",
164 | "npx",
165 | "-y",
166 | "@modelcontextprotocol/server-filesystem",
167 | "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit"
168 | ]
169 | }
170 | },
171 | "operations": [
172 | {
173 | "tool": "read_file",
174 | "arguments": {
175 | "path": "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit/package.json"
176 | }
177 | },
178 | {
179 | "tool": "read_file",
180 | "arguments": {
181 | "path": "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit/README.md"
182 | }
183 | }
184 | ],
185 | "options": {
186 | "maxConcurrent": 2,
187 | "stopOnError": true,
188 | "timeoutMs": 30000
189 | }
190 | }
191 | ```
192 |
193 | **Note**: The aggregator spawns `@modelcontextprotocol/server-filesystem` (via `npx`) to execute parallel `read_file` operations.
194 |
195 | #### LLM‐Only Step (List Code Definitions)
196 |
197 | This phase involves processing outside the aggregator, typically using LLM or AI agent capabilities:
198 |
199 | ```typescript
200 | <list_code_definition_names>
201 | <path>src</path>
202 | </list_code_definition_names>
203 | ```
204 |
205 | This step utilizes Roo Code's `list_code_definition_names` tool, which is exclusively available to LLMs. However, note that many MCP servers can provide similar functionality, making it possible to complete this process without LLM requests.
206 |
207 | #### Document Creation
208 |
209 | The final phase combines data from previous steps (file contents and code definitions) to generate documentation in the `memory-bank` directory:
210 |
211 | ```jsonc
212 | {
213 | "targetServer": {
214 | "name": "filesystem",
215 | "serverType": {
216 | "type": "filesystem",
217 | "config": {
218 | "rootDirectory": "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit"
219 | }
220 | },
221 | "transport": {
222 | "type": "stdio",
223 | "command": "cmd.exe",
224 | "args": [
225 | "/c",
226 | "npx",
227 | "-y",
228 | "@modelcontextprotocol/server-filesystem",
229 | "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit"
230 | ]
231 | }
232 | },
233 | "operations": [
234 | {
235 | "tool": "create_directory",
236 | "arguments": {
237 | "path": "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit/memory-bank"
238 | }
239 | },
240 | {
241 | "tool": "write_file",
242 | "arguments": {
243 | "path": "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit/memory-bank/productContext.md",
244 | "content": "# MCP BatchIt Product Context\\n\\n## Purpose\\n..."
245 | }
246 | },
247 | {
248 | "tool": "write_file",
249 | "arguments": {
250 | "path": "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit/memory-bank/systemPatterns.md",
251 | "content": "# MCP BatchIt System Patterns\\n\\n## Architecture Overview\\n..."
252 | }
253 | },
254 | {
255 | "tool": "write_file",
256 | "arguments": {
257 | "path": "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit/memory-bank/techContext.md",
258 | "content": "# MCP BatchIt Technical Context\\n\\n## Technology Stack\\n..."
259 | }
260 | },
261 | {
262 | "tool": "write_file",
263 | "arguments": {
264 | "path": "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit/memory-bank/progress.md",
265 | "content": "# MCP BatchIt Progress Status\\n\\n## Completed Features\\n..."
266 | }
267 | },
268 | {
269 | "tool": "write_file",
270 | "arguments": {
271 | "path": "C:/Users/Chewy/Documents/GitHub/ryanjoachim/mcp-batchit/memory-bank/activeContext.md",
272 | "content": "# MCP BatchIt Active Context\\n\\n## Current Status\\n..."
273 | }
274 | }
275 | ],
276 | "options": {
277 | "maxConcurrent": 1,
278 | "stopOnError": true,
279 | "timeoutMs": 30000
280 | }
281 | }
282 | ```
283 |
284 | The aggregator processes these operations sequentially (`maxConcurrent=1`), creating the directory and writing multiple documentation files. The result array indicates the success/failure status of each operation.
285 |
286 | ---
287 |
288 | ## FAQ
289 |
290 | **Q1: Do I need multiple aggregator calls if sub-op #2 depends on sub-op #1’s results?**
291 | **Yes.** BatchIt doesn’t pass data between sub-ops in the same request. You do multi-phase calls (like the example above).
292 |
293 | **Q2: Why do I get “Tool create_directory not found” sometimes?**
294 | Because your `transport` might be pointing to the aggregator script itself instead of the real MCP server. Make sure you reference something like `@modelcontextprotocol/server-filesystem`.
295 |
296 | **Q3: Can I do concurrency plus stopOnError?**
297 | Absolutely. If a sub-op fails, we skip launching new sub-ops. Already-running ones finish in parallel.
298 |
299 | **Q4: Does BatchIt re-spawn the target server each time?**
300 | It *can* if you specify `keepAlive: false`. But if you use the same exact `targetServer.name + transport`, it caches the connection until an idle timeout passes.
301 |
302 | **Q5: Are partial results returned if an error occurs in the middle?**
303 | Yes. Each sub-op that finished prior to the error is included in the final aggregator response, along with the failing sub-op. Remaining sub-ops are skipped if `stopOnError` is true.
304 |
305 | ---
306 |
307 | ## License
308 |
309 | **MIT**
310 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "node16",
6 | "declaration": true,
7 | "declarationMap": true,
8 | "sourceMap": true,
9 | "outDir": "./build",
10 | "rootDir": "./src",
11 | "strict": true,
12 | "noImplicitAny": true,
13 | "strictNullChecks": true,
14 | "strictFunctionTypes": true,
15 | "strictBindCallApply": true,
16 | "strictPropertyInitialization": true,
17 | "noImplicitThis": true,
18 | "alwaysStrict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noImplicitReturns": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "allowJs": true,
24 | "resolveJsonModule": true,
25 | "allowSyntheticDefaultImports": true,
26 | "esModuleInterop": true,
27 | "experimentalDecorators": true,
28 | "emitDecoratorMetadata": true,
29 | "skipLibCheck": true,
30 | "forceConsistentCasingInFileNames": true
31 | },
32 | "include": ["src/**/*"],
33 | "exclude": ["node_modules", "build", "**/*.test.ts"]
34 | }
35 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "mcp-batchit",
3 | "version": "1.0.0",
4 | "description": "Batch multiple MCP tool calls into a single request—reducing overhead and token usage for AI agents",
5 | "main": "build/index.js",
6 | "type": "module",
7 | "types": "build/index.d.ts",
8 | "bin": {
9 | "mcp-batchit": "./build/index.js"
10 | },
11 | "files": [
12 | "build"
13 | ],
14 | "scripts": {
15 | "build": "tsc",
16 | "prepare": "npm run build",
17 | "watch": "tsc --watch",
18 | "test": "jest",
19 | "lint": "eslint src --ext .ts",
20 | "clean": "rimraf build"
21 | },
22 | "config": {
23 | "run-script": {
24 | "build": "npm run build"
25 | }
26 | },
27 | "keywords": [
28 | "mcp",
29 | "modelcontextprotocol",
30 | "batch",
31 | "operations",
32 | "ai",
33 | "llm",
34 | "agent",
35 | "filesystem",
36 | "aggregator"
37 | ],
38 | "author": "Ryan Joachim",
39 | "repository": {
40 | "type": "git",
41 | "url": "https://github.com/ryanjoachim/mcp-batchit.git"
42 | },
43 | "license": "MIT",
44 | "dependencies": {
45 | "@modelcontextprotocol/sdk": "^1.4.0"
46 | },
47 | "devDependencies": {
48 | "@types/jest": "^29.5.11",
49 | "@types/node": "^22.8.0",
50 | "jest": "^29.7.0",
51 | "rimraf": "^5.0.5",
52 | "ts-jest": "^29.1.1",
53 | "ts-node": "^10.9.2",
54 | "typescript": "^5.7.2"
55 | }
56 | }
57 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 |
3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
6 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"
7 | import { WebSocketClientTransport } from "@modelcontextprotocol/sdk/client/websocket.js"
8 | import { z } from "zod"
9 | import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
10 | import { ChildProcess } from "child_process"
11 | import { existsSync } from "fs"
12 | import { isAbsolute } from "path"
13 |
14 | // Array of patterns that indicate self-referential usage
15 | const SELF_REFERENCE_PATTERNS = [
16 | // Direct file path references
17 | "mcp-batchit/build/index.js",
18 | "mcp-batchit/dist/index.js",
19 | "mcp-batchit/lib/index.js",
20 |
21 | // NPM package references
22 | "@modelcontextprotocol/batchit",
23 | "@modelcontextprotocol/server-batchit",
24 |
25 | // Common variations
26 | "mcp-batchit",
27 | "batchit",
28 | "server-batchit",
29 | ]
30 |
31 | // Transport error handling
32 | enum TransportErrorType {
33 | CommandNotFound = "CommandNotFound",
34 | ConnectionFailed = "ConnectionFailed",
35 | ValidationFailed = "ValidationFailed",
36 | ConfigurationInvalid = "ConfigurationInvalid",
37 | }
38 |
39 | class TransportError extends Error {
40 | constructor(
41 | public type: TransportErrorType,
42 | message: string,
43 | public cause?: Error
44 | ) {
45 | super(message)
46 | this.name = "TransportError"
47 | Error.captureStackTrace(this, TransportError)
48 | }
49 | }
50 |
51 | // Server Type Definitions
52 | interface FilesystemServerConfig {
53 | rootDirectory?: string
54 | permissions?: string
55 | watchMode?: boolean
56 | }
57 |
58 | interface DatabaseServerConfig {
59 | database: string
60 | readOnly?: boolean
61 | poolSize?: number
62 | }
63 |
64 | interface GenericServerConfig {
65 | [key: string]: unknown
66 | }
67 |
68 | type ServerType =
69 | | { type: "filesystem"; config: FilesystemServerConfig }
70 | | { type: "database"; config: DatabaseServerConfig }
71 | | { type: "generic"; config: GenericServerConfig }
72 |
73 | // Transport Configuration
74 | type TransportConfig =
75 | | {
76 | type: "stdio"
77 | command: string
78 | args?: string[]
79 | env?: Record<string, string>
80 | }
81 | | {
82 | type: "websocket"
83 | url: string
84 | options?: Record<string, unknown>
85 | }
86 |
87 | interface ServerIdentity {
88 | name: string
89 | serverType: ServerType
90 | transport: TransportConfig
91 | maxIdleTimeMs?: number
92 | }
93 |
94 | interface ServerConnection {
95 | client: Client
96 | transport: WebSocketClientTransport | StdioClientTransport
97 | childProcess?: ChildProcess
98 | lastUsed: number
99 | identity: ServerIdentity
100 | }
101 |
102 | interface HPCContentItem {
103 | type: string
104 | text?: string
105 | }
106 |
107 | interface HPCErrorResponse {
108 | isError: true
109 | error?: string
110 | message?: string
111 | content?: HPCContentItem[]
112 | }
113 |
114 | function isHPCErrorResponse(value: unknown): value is HPCErrorResponse {
115 | return (
116 | value !== null &&
117 | typeof value === "object" &&
118 | "isError" in value &&
119 | value.isError === true
120 | )
121 | }
122 |
123 | // Type guard for StdioClientTransport
124 | function isStdioTransport(transport: any): transport is StdioClientTransport {
125 | return "start" in transport
126 | }
127 |
128 | // Schema Definitions
129 | const ServerTypeSchema = z.discriminatedUnion("type", [
130 | z.object({
131 | type: z.literal("filesystem"),
132 | config: z.object({
133 | rootDirectory: z.string().optional(),
134 | permissions: z.string().optional(),
135 | watchMode: z.boolean().optional(),
136 | }),
137 | }),
138 | z.object({
139 | type: z.literal("database"),
140 | config: z.object({
141 | database: z.string(),
142 | readOnly: z.boolean().optional(),
143 | poolSize: z.number().optional(),
144 | }),
145 | }),
146 | z.object({
147 | type: z.literal("generic"),
148 | config: z.record(z.unknown()),
149 | }),
150 | ])
151 |
152 | const TransportConfigSchema = z.discriminatedUnion("type", [
153 | z.object({
154 | type: z.literal("stdio"),
155 | command: z.string(),
156 | args: z.array(z.string()).optional(),
157 | env: z.record(z.string()).optional(),
158 | }),
159 | z.object({
160 | type: z.literal("websocket"),
161 | url: z.string(),
162 | options: z.record(z.unknown()).optional(),
163 | }),
164 | ])
165 |
166 | const BatchArgsSchema = z.object({
167 | targetServer: z.object({
168 | name: z.string(),
169 | serverType: ServerTypeSchema,
170 | transport: TransportConfigSchema,
171 | maxIdleTimeMs: z.number().optional(),
172 | }),
173 | operations: z.array(
174 | z.object({
175 | tool: z.string(),
176 | arguments: z.record(z.unknown()).default({}),
177 | })
178 | ),
179 | options: z
180 | .object({
181 | maxConcurrent: z.number().default(10),
182 | timeoutMs: z.number().default(30000),
183 | stopOnError: z.boolean().default(false),
184 | keepAlive: z.boolean().default(false),
185 | })
186 | .default({
187 | maxConcurrent: 10,
188 | timeoutMs: 30000,
189 | stopOnError: false,
190 | keepAlive: false,
191 | }),
192 | })
193 |
194 | // Connection Management
195 | class ConnectionManager {
196 | private connections = new Map<string, ServerConnection>()
197 | private cleanupIntervals = new Map<string, NodeJS.Timeout>()
198 |
199 | createKeyForIdentity(identity: ServerIdentity): string {
200 | return JSON.stringify({
201 | name: identity.name,
202 | serverType: identity.serverType,
203 | transport: identity.transport,
204 | })
205 | }
206 |
207 | private validateStdioConfig(
208 | config: Extract<TransportConfig, { type: "stdio" }>
209 | ) {
210 | if (!config.command) {
211 | throw new TransportError(
212 | TransportErrorType.ConfigurationInvalid,
213 | "Command is required for stdio transport"
214 | )
215 | }
216 |
217 | if (!config.args?.length) {
218 | throw new TransportError(
219 | TransportErrorType.ConfigurationInvalid,
220 | "At least one argument (server file path) is required"
221 | )
222 | }
223 |
224 | // For node commands, validate the file exists
225 | if (config.command === "node") {
226 | const serverFile = config.args[0]
227 | if (!isAbsolute(serverFile)) {
228 | throw new TransportError(
229 | TransportErrorType.ConfigurationInvalid,
230 | "Server file path must be absolute when using node command"
231 | )
232 | }
233 | if (!existsSync(serverFile)) {
234 | throw new TransportError(
235 | TransportErrorType.ValidationFailed,
236 | `Server file not found: ${serverFile}`
237 | )
238 | }
239 |
240 | // Prevent the BatchIt aggregator from spawning itself
241 | const fullCommand = [config.command, ...(config.args || [])].join(" ")
242 | if (
243 | SELF_REFERENCE_PATTERNS.some((pattern) =>
244 | fullCommand.toLowerCase().includes(pattern.toLowerCase())
245 | )
246 | ) {
247 | throw new TransportError(
248 | TransportErrorType.ConfigurationInvalid,
249 | "Cannot spawn the BatchIt aggregator itself. Provide a valid MCP server file instead."
250 | )
251 | }
252 | }
253 | }
254 |
255 | private validateWebSocketConfig(
256 | config: Extract<TransportConfig, { type: "websocket" }>
257 | ) {
258 | try {
259 | const url = new URL(config.url)
260 | if (url.protocol !== "ws:" && url.protocol !== "wss:") {
261 | throw new TransportError(
262 | TransportErrorType.ConfigurationInvalid,
263 | "WebSocket URL must use ws:// or wss:// protocol"
264 | )
265 | }
266 | } catch (error) {
267 | throw new TransportError(
268 | TransportErrorType.ConfigurationInvalid,
269 | "Invalid WebSocket URL",
270 | error instanceof Error ? error : undefined
271 | )
272 | }
273 | }
274 |
275 | async getOrCreateConnection(
276 | identity: ServerIdentity
277 | ): Promise<ServerConnection> {
278 | const serverKey = this.createKeyForIdentity(identity)
279 |
280 | if (this.connections.has(serverKey)) {
281 | const conn = this.connections.get(serverKey)!
282 | conn.lastUsed = Date.now()
283 | return conn
284 | }
285 |
286 | const transport = await this.createTransport(identity.transport)
287 | const client = new Client(
288 | { name: "mcp-batchit", version: "1.0.0" },
289 | { capabilities: {} }
290 | )
291 |
292 | await client.connect(transport)
293 |
294 | const connection: ServerConnection = {
295 | client,
296 | transport,
297 | lastUsed: Date.now(),
298 | identity,
299 | }
300 |
301 | this.connections.set(serverKey, connection)
302 | this.setupMonitoring(serverKey, connection)
303 | this.setupCleanupInterval(serverKey)
304 |
305 | return connection
306 | }
307 |
308 | private async createTransport(
309 | config: TransportConfig
310 | ): Promise<WebSocketClientTransport | StdioClientTransport> {
311 | switch (config.type) {
312 | case "stdio": {
313 | try {
314 | this.validateStdioConfig(config)
315 |
316 | try {
317 | const transport = new StdioClientTransport({
318 | command: config.command,
319 | args: config.args,
320 | env: config.env,
321 | stderr: "pipe",
322 | })
323 |
324 | // Test the transport
325 | return transport
326 | } catch (error) {
327 | if (
328 | error &&
329 | typeof error === "object" &&
330 | "code" in error &&
331 | error.code === "ENOENT"
332 | ) {
333 | throw new TransportError(
334 | TransportErrorType.CommandNotFound,
335 | `Command '${config.command}' not found in PATH. If using 'npx', ensure it's installed globally. Consider using 'node' with direct path to server JS file instead.`
336 | )
337 | }
338 | throw error
339 | }
340 | } catch (error) {
341 | if (error instanceof TransportError) {
342 | throw new McpError(ErrorCode.InvalidParams, error.message)
343 | } else {
344 | throw new McpError(
345 | ErrorCode.InternalError,
346 | `Failed to create stdio transport: ${
347 | error instanceof Error ? error.message : String(error)
348 | }`
349 | )
350 | }
351 | }
352 | }
353 |
354 | case "websocket": {
355 | try {
356 | this.validateWebSocketConfig(config)
357 |
358 | const wsUrl =
359 | config.url.startsWith("ws://") || config.url.startsWith("wss://")
360 | ? config.url
361 | : `ws://${config.url}`
362 |
363 | const transport = new WebSocketClientTransport(new URL(wsUrl))
364 | return transport
365 | } catch (error) {
366 | if (error instanceof TransportError) {
367 | throw new McpError(ErrorCode.InvalidParams, error.message)
368 | } else {
369 | throw new McpError(
370 | ErrorCode.InternalError,
371 | `Failed to create WebSocket transport: ${
372 | error instanceof Error ? error.message : String(error)
373 | }`
374 | )
375 | }
376 | }
377 | }
378 | }
379 | }
380 |
381 | private setupMonitoring(
382 | serverKey: string,
383 | connection: ServerConnection
384 | ): void {
385 | if (isStdioTransport(connection.transport)) {
386 | // For stdio transports, we can monitor stderr
387 | const stderr = connection.transport.stderr
388 | if (stderr) {
389 | stderr.on("data", (data: Buffer) => {
390 | console.error(`[${connection.identity.name}] ${data.toString()}`)
391 | })
392 | }
393 | }
394 |
395 | // Monitor transport errors
396 | connection.transport.onerror = (error: Error) => {
397 | console.error(`Transport error:`, error)
398 | this.closeConnection(serverKey)
399 | }
400 | }
401 |
402 | private setupCleanupInterval(serverKey: string): void {
403 | const interval = setInterval(() => {
404 | const conn = this.connections.get(serverKey)
405 | if (!conn) return
406 |
407 | const idleTime = Date.now() - conn.lastUsed
408 | if (idleTime > (conn.identity.maxIdleTimeMs ?? 300000)) {
409 | // 5min default
410 | this.closeConnection(serverKey)
411 | }
412 | }, 60000) // Check every minute
413 |
414 | this.cleanupIntervals.set(serverKey, interval)
415 | }
416 |
417 | async closeConnection(serverKey: string): Promise<void> {
418 | const conn = this.connections.get(serverKey)
419 | if (!conn) return
420 |
421 | try {
422 | await conn.client.close()
423 | await conn.transport.close()
424 | } catch (error) {
425 | console.error(`Error closing connection for ${serverKey}:`, error)
426 | }
427 |
428 | this.connections.delete(serverKey)
429 |
430 | const interval = this.cleanupIntervals.get(serverKey)
431 | if (interval) {
432 | clearInterval(interval)
433 | this.cleanupIntervals.delete(serverKey)
434 | }
435 | }
436 |
437 | async closeAll(): Promise<void> {
438 | for (const serverKey of this.connections.keys()) {
439 | await this.closeConnection(serverKey)
440 | }
441 | }
442 | }
443 |
444 | // Batch Execution
445 | interface Operation {
446 | tool: string
447 | arguments: Record<string, unknown>
448 | }
449 |
450 | interface OperationResult {
451 | tool: string
452 | success: boolean
453 | result?: unknown
454 | error?: string
455 | durationMs: number
456 | }
457 |
458 | class BatchExecutor {
459 | constructor(private connectionManager: ConnectionManager) {}
460 |
461 | async executeBatch(
462 | identity: ServerIdentity,
463 | operations: Operation[],
464 | options: {
465 | maxConcurrent: number
466 | timeoutMs: number
467 | stopOnError: boolean
468 | keepAlive?: boolean
469 | }
470 | ): Promise<OperationResult[]> {
471 | const connection = await this.connectionManager.getOrCreateConnection(
472 | identity
473 | )
474 |
475 | const results: OperationResult[] = []
476 | const pending = [...operations]
477 | const running = new Set<Promise<OperationResult>>()
478 |
479 | try {
480 | while (pending.length > 0 || running.size > 0) {
481 | while (pending.length > 0 && running.size < options.maxConcurrent) {
482 | const op = pending.shift()!
483 | const promise = this.executeOperation(
484 | connection,
485 | op,
486 | options.timeoutMs
487 | )
488 | running.add(promise)
489 |
490 | promise.then((res) => {
491 | running.delete(promise)
492 | results.push(res)
493 | if (!res.success && options.stopOnError) {
494 | pending.length = 0
495 | }
496 | })
497 | }
498 |
499 | if (running.size > 0) {
500 | await Promise.race(running)
501 | }
502 | }
503 | } finally {
504 | if (!options.keepAlive) {
505 | await this.connectionManager.closeConnection(
506 | this.connectionManager.createKeyForIdentity(identity)
507 | )
508 | }
509 | }
510 |
511 | return results
512 | }
513 |
514 | private getErrorMessage(result: HPCErrorResponse): string {
515 | // Direct error/message properties
516 | if (result.error || result.message) {
517 | return result.error ?? result.message ?? "Unknown HPC error"
518 | }
519 |
520 | // Look for error in content array
521 | if (result.content?.length) {
522 | const textContent = result.content
523 | .filter((item) => item.type === "text" && item.text)
524 | .map((item) => item.text)
525 | .filter((text): text is string => text !== undefined)
526 | .join(" ")
527 |
528 | if (textContent) {
529 | return textContent
530 | }
531 | }
532 |
533 | return "Unknown HPC error"
534 | }
535 |
536 | private async executeOperation(
537 | connection: ServerConnection,
538 | operation: Operation,
539 | timeoutMs: number
540 | ): Promise<OperationResult> {
541 | const start = Date.now()
542 | try {
543 | const result = await Promise.race([
544 | connection.client.callTool({
545 | name: operation.tool,
546 | arguments: operation.arguments,
547 | }),
548 | new Promise<never>((_, reject) =>
549 | setTimeout(
550 | () =>
551 | reject(
552 | new McpError(ErrorCode.RequestTimeout, "Operation timed out")
553 | ),
554 | timeoutMs
555 | )
556 | ),
557 | ])
558 |
559 | if (isHPCErrorResponse(result)) {
560 | return {
561 | tool: operation.tool,
562 | success: false,
563 | error: this.getErrorMessage(result),
564 | durationMs: Date.now() - start,
565 | }
566 | }
567 |
568 | return {
569 | tool: operation.tool,
570 | success: true,
571 | result,
572 | durationMs: Date.now() - start,
573 | }
574 | } catch (error) {
575 | return {
576 | tool: operation.tool,
577 | success: false,
578 | error: error instanceof Error ? error.message : String(error),
579 | durationMs: Date.now() - start,
580 | }
581 | }
582 | }
583 | }
584 |
585 | // Server Setup
586 | const connectionManager = new ConnectionManager()
587 | const batchExecutor = new BatchExecutor(connectionManager)
588 | const server = new McpServer({
589 | name: "mcp-batchit",
590 | version: "1.0.0",
591 | })
592 |
593 | // Define the tool's schema shape (required properties for tool registration)
594 | const toolSchema = {
595 | targetServer: BatchArgsSchema.shape.targetServer,
596 | operations: BatchArgsSchema.shape.operations,
597 | options: BatchArgsSchema.shape.options,
598 | }
599 |
600 | server.tool(
601 | "batch_execute",
602 | `
603 | Execute multiple operations in batch on a specified MCP server. You must provide a real MCP server (like @modelcontextprotocol/server-filesystem). The aggregator will reject any attempt to spawn itself.
604 |
605 | Transport Configuration:
606 |
607 | 1. For stdio transport (recommended for local servers):
608 | Using node with direct file path (preferred):
609 | {
610 | "transport": {
611 | "type": "stdio",
612 | "command": "node",
613 | "args": ["C:/path/to/server.js"]
614 | }
615 | }
616 |
617 | Using npx (requires global npx installation):
618 | {
619 | "transport": {
620 | "type": "stdio",
621 | "command": "npx",
622 | "args": ["@modelcontextprotocol/server-filesystem"]
623 | }
624 | }
625 |
626 | 2. For WebSocket transport (for connecting to running servers):
627 | {
628 | "transport": {
629 | "type": "websocket",
630 | "url": "ws://localhost:3000"
631 | }
632 | }
633 |
634 | Usage:
635 | - Provide "targetServer" configuration with:
636 | - name: Unique identifier for the server
637 | - serverType: Type and configuration of the server (filesystem, database, or generic)
638 | - transport: Connection method (stdio or websocket) and its configuration
639 | - Provide "operations" as an array of objects with:
640 | - tool: The tool name on the target server
641 | - arguments: The JSON arguments to pass
642 | - Options:
643 | - maxConcurrent: Maximum concurrent operations (default: 10)
644 | - timeoutMs: Timeout per operation in milliseconds (default: 30000)
645 | - stopOnError: Whether to stop on first error (default: false)
646 | - keepAlive: Keep connection after batch completion (default: false)
647 |
648 | Complete Example:
649 | {
650 | "targetServer": {
651 | "name": "local-fs",
652 | "serverType": {
653 | "type": "filesystem",
654 | "config": {
655 | "rootDirectory": "C:/data",
656 | "watchMode": true
657 | }
658 | },
659 | "transport": {
660 | "type": "stdio",
661 | "command": "node",
662 | "args": ["C:/path/to/filesystem-server.js"]
663 | }
664 | },
665 | "operations": [
666 | { "tool": "createFile", "arguments": { "path": "test1.txt", "content": "Hello" } },
667 | { "tool": "createFile", "arguments": { "path": "test2.txt", "content": "World" } }
668 | ],
669 | "options": {
670 | "maxConcurrent": 3,
671 | "stopOnError": true
672 | }
673 | }`,
674 | toolSchema,
675 | async (args) => {
676 | const parsed = BatchArgsSchema.safeParse(args)
677 | if (!parsed.success) {
678 | throw new McpError(ErrorCode.InvalidParams, parsed.error.message)
679 | }
680 |
681 | const { targetServer, operations, options } = parsed.data
682 |
683 | const results = await batchExecutor.executeBatch(
684 | targetServer,
685 | operations,
686 | options
687 | )
688 |
689 | return {
690 | content: [
691 | {
692 | type: "text",
693 | text: JSON.stringify(
694 | {
695 | targetServer: targetServer.name,
696 | summary: {
697 | successCount: results.filter((r) => r.success).length,
698 | failCount: results.filter((r) => !r.success).length,
699 | totalDurationMs: results.reduce(
700 | (sum, r) => sum + r.durationMs,
701 | 0
702 | ),
703 | },
704 | operations: results,
705 | },
706 | null,
707 | 2
708 | ),
709 | },
710 | ],
711 | }
712 | }
713 | )
714 |
715 | // Startup
716 | ;(async function main() {
717 | const transport = new StdioServerTransport()
718 | await server.connect(transport)
719 |
720 | console.error("mcp-batchit is running on stdio. Ready to batch-execute!")
721 |
722 | process.on("SIGINT", cleanup)
723 | process.on("SIGTERM", cleanup)
724 | })().catch((err) => {
725 | console.error("Fatal error in aggregator server:", err)
726 | process.exit(1)
727 | })
728 |
729 | async function cleanup() {
730 | console.error("Shutting down, closing all connections...")
731 | await connectionManager.closeAll()
732 | await server.close()
733 | process.exit(0)
734 | }
735 |
```