#
tokens: 10576/50000 5/5 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 | 
```