typescript-async-patterns
Async/await, concurrency control, and error propagation patterns for MCP documentation server performance
$ npx -y skills add notque/vexjoy-agent --agent claude-codeHow it fires
How this agent gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
Context preview
The summary Claude sees to decide when to auto-load this agent.
Async/await, concurrency control, and error propagation patterns for MCP documentation server performance
Agent definition
typescript-async-patterns.mddescription: Async/await, concurrency control, and error propagation patterns for MCP documentation server performance
TypeScript Async Patterns for MCP Servers
> **Scope**: MCP server file I/O concurrency, indexing performance, error propagation. Node.js 18+, TypeScript 5.0+.
Pattern Table
| Pattern | Version | Use When | Avoid When | |---------|---------|----------|------------| | `Promise.all(batch)` | Node 18+ | Bounded concurrent I/O | Unbounded arrays (OOM risk) | | `Promise.allSettled()` | Node 12.9+ | Must collect all results despite failures | Need fast-fail on first error | | `Promise.race()` | Node 18+ | Startup timeout racing | Error handling (loses other results) | | `async/await` in handlers | Always | All `setRequestHandler` callbacks | Sync callbacks (blocks event loop) | | `AbortController` | Node 18+ | Cancellable indexing operations | Simple timeouts (use setTimeout) |
---
Correct Patterns
Bounded Concurrency for File Indexing
Fixed-size batches to avoid exhausting file descriptors.
async function indexFilesInBatches(
files: string[],
batchSize: number,
processFile: (path: string) => Promise<void>
): Promise<{ processed: number; failed: number }> {
let processed = 0;
let failed = 0;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const results = await Promise.allSettled(batch.map(processFile));
for (const result of results) {
if (result.status === 'fulfilled') {
processed++;
} else {
failed++;
console.warn(`[warn] indexing failed: ${result.reason}`);
}
}
}
return { processed, failed };
}
// Usage: process 50 files at a time
await indexFilesInBatches(allFiles, 50, (f) => this.parseAndCache(f));**Why**: `Promise.all(files.map(...))` on 10,000 files opens 10,000 file handles simultaneously. Most OS defaults limit to 1024 open file descriptors. `EMFILE: too many open files` crashes the indexer.
---
Startup Timeout with Partial Results
Serve before indexing completes.
class DocsServer {
private indexingComplete = false;
async start(): Promise<void> {
const STARTUP_TIMEOUT_MS = 15_000;
const indexingDone = this.runIndexing();
// Serve partial results after timeout — don't block connection
const startupFence = Promise.race([
indexingDone.then(() => { this.indexingComplete = true; }),
new Promise<void>((resolve) => setTimeout(resolve, STARTUP_TIMEOUT_MS)),
]);
await startupFence;
const transport = new StdioServerTransport();
await this.server.connect(transport);
// Indexing may still be running in background
await indexingDone.then(() => { this.indexingComplete = true; }).catch(() => {});
}
// Add indexing status to list response metadata
handleListResources() {
return {
resources: this.getIndexedResources(),
_meta: this.indexingComplete ? undefined : { indexing: true },
};
}
}**Why**: MCP clients (Claude Desktop) have a 30-second connection timeout. A 10,000-file corpus can take 60+ seconds to index sequentially. Without a startup fence, Claude never connects.
---
Error Propagation with Context
Preserve context through async chains.
async function parseDocWithContext(filePath: string): Promise<ParsedDoc> {
try {
const content = await fs.promises.readFile(filePath, 'utf-8');
return parseMarkdownDoc(filePath, content);
} catch (err) {
// Wrap with file context before re-throwing
throw new Error(`Failed to parse ${filePath}: ${(err as Error).message}`, {
cause: err, // Node 16.9+ / ES2022
});
}
}
// Caller can inspect cause:
try {
await parseDocWithContext('/docs/broken.md');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
// File disappeared between glob and read — normal race condition
}
console.error(err.message); // "Failed to parse /docs/broken.md: ENOENT..."
console.error('Caused by:', (err as Error).cause);
}**Why**: `cause` option (ES2022, Node 16.9+) enables error chains without losing the original. Without it, wrapping with template strings loses the original stack trace.
---
Pattern Catalog
Batch File Operations with Bounded Concurrency
**Detection**:
# Find Promise.all with .map() on potentially large arrays
grep -rn 'Promise\.all.*\.map' --include="*.ts" src/
rg 'Promise\.all\(' --type ts src/ -A2 | grep '\.map'**Signal**:
// Reads ALL files simultaneously — EMFILE on large repos
const docs = await Promise.all(
allFiles.map((f) => fs.promises.readFile(f, 'utf-8'))
);
**Why this matters**: `EMFILE: too many open files` at runtime. Default ulimit is 1024 file descriptors on Linux. A repo with 2000 markdown files triggers this. The error appears non-deterministically depending on OS load, making it hard to reproduce locally.
**Preferred action**:
// Process in batches of 50
for (let i = 0; i < allFiles.length; i += 50) {
await Promise.all(allFiles.slice(i, i + 50).map((f) => this.parseAndCache(f)));
}**Version note**: Node.js 18+ raises default file descriptor limit on Linux but OS limits still apply. Don't rely on this.
---
Use async/await in All Request Handlers
**Detection**:
grep -rn 'setRequestHandler' --include="*.ts" src/ -A10 | grep -v 'async\|await\|return'
# More specifically: promises not awaited inside handlers
rg 'setRequestHandler.*\{' --type ts src/ -A5 | grep '^\s*[a-zA-Z].*\(\)' | grep -v 'await'**Signal**:
server.setRequestHandler(CallToolRequestSchema, (request) => {
// Not async, not returning promise properly
this.searchIndex(request.params.arguments?.query).then((results) => {
return { content: [{ type: 'text', text: JSON.stringify(results) }] };
});
// Returns undefined! Handler completes before search finishes.
});**Why this matters**: The ha
Read more
description: Async/await, concurrency control, and error propagation patterns for MCP documentation server performance
TypeScript Async Patterns for MCP Servers
> **Scope**: MCP server file I/O concurrency, indexing performance, error propagation. Node.js 18+, TypeScript 5.0+.
Pattern Table
| Pattern | Version | Use When | Avoid When | |---------|---------|----------|------------| | `Promise.all(batch)` | Node 18+ | Bounded concurrent I/O | Unbounded arrays (OOM risk) | | `Promise.allSettled()` | Node 12.9+ | Must collect all results despite failures | Need fast-fail on first error | | `Promise.race()` | Node 18+ | Startup timeout racing | Error handling (loses other results) | | `async/await` in handlers | Always | All `setRequestHandler` callbacks | Sync callbacks (blocks event loop) | | `AbortController` | Node 18+ | Cancellable indexing operations | Simple timeouts (use setTimeout) |
---
Correct Patterns
Bounded Concurrency for File Indexing
Fixed-size batches to avoid exhausting file descriptors.
async function indexFilesInBatches(
files: string[],
batchSize: number,
processFile: (path: string) => Promise<void>
): Promise<{ processed: number; failed: number }> {
let processed = 0;
let failed = 0;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const results = await Promise.allSettled(batch.map(processFile));
for (const result of results) {
if (result.status === 'fulfilled') {
processed++;
} else {
failed++;
console.warn(`[warn] indexing failed: ${result.reason}`);
}
}
}
return { processed, failed };
}
// Usage: process 50 files at a time
await indexFilesInBatches(allFiles, 50, (f) => this.parseAndCache(f));**Why**: `Promise.all(files.map(...))` on 10,000 files opens 10,000 file handles simultaneously. Most OS defaults limit to 1024 open file descriptors. `EMFILE: too many open files` crashes the indexer.
---
Startup Timeout with Partial Results
Serve before indexing completes.
class DocsServer {
private indexingComplete = false;
async start(): Promise<void> {
const STARTUP_TIMEOUT_MS = 15_000;
const indexingDone = this.runIndexing();
// Serve partial results after timeout — don't block connection
const startupFence = Promise.race([
indexingDone.then(() => { this.indexingComplete = true; }),
new Promise<void>((resolve) => setTimeout(resolve, STARTUP_TIMEOUT_MS)),
]);
await startupFence;
const transport = new StdioServerTransport();
await this.server.connect(transport);
// Indexing may still be running in background
await indexingDone.then(() => { this.indexingComplete = true; }).catch(() => {});
}
// Add indexing status to list response metadata
handleListResources() {
return {
resources: this.getIndexedResources(),
_meta: this.indexingComplete ? undefined : { indexing: true },
};
}
}**Why**: MCP clients (Claude Desktop) have a 30-second connection timeout. A 10,000-file corpus can take 60+ seconds to index sequentially. Without a startup fence, Claude never connects.
---
Error Propagation with Context
Preserve context through async chains.
async function parseDocWithContext(filePath: string): Promise<ParsedDoc> {
try {
const content = await fs.promises.readFile(filePath, 'utf-8');
return parseMarkdownDoc(filePath, content);
} catch (err) {
// Wrap with file context before re-throwing
throw new Error(`Failed to parse ${filePath}: ${(err as Error).message}`, {
cause: err, // Node 16.9+ / ES2022
});
}
}
// Caller can inspect cause:
try {
await parseDocWithContext('/docs/broken.md');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
// File disappeared between glob and read — normal race condition
}
console.error(err.message); // "Failed to parse /docs/broken.md: ENOENT..."
console.error('Caused by:', (err as Error).cause);
}**Why**: `cause` option (ES2022, Node 16.9+) enables error chains without losing the original. Without it, wrapping with template strings loses the original stack trace.
---
Pattern Catalog
Batch File Operations with Bounded Concurrency
**Detection**:
# Find Promise.all with .map() on potentially large arrays
grep -rn 'Promise\.all.*\.map' --include="*.ts" src/
rg 'Promise\.all\(' --type ts src/ -A2 | grep '\.map'**Signal**:
// Reads ALL files simultaneously — EMFILE on large repos const docs = await Promise.all( allFiles.map((f) => fs.promises.readFile(f, 'utf-8')) );
**Why this matters**: `EMFILE: too many open files` at runtime. Default ulimit is 1024 file descriptors on Linux. A repo with 2000 markdown files triggers this. The error appears non-deterministically depending on OS load, making it hard to reproduce locally.
**Preferred action**:
// Process in batches of 50
for (let i = 0; i < allFiles.length; i += 50) {
await Promise.all(allFiles.slice(i, i + 50).map((f) => this.parseAndCache(f)));
}**Version note**: Node.js 18+ raises default file descriptor limit on Linux but OS limits still apply. Don't rely on this.
---
Use async/await in All Request Handlers
**Detection**:
grep -rn 'setRequestHandler' --include="*.ts" src/ -A10 | grep -v 'async\|await\|return'
# More specifically: promises not awaited inside handlers
rg 'setRequestHandler.*\{' --type ts src/ -A5 | grep '^\s*[a-zA-Z].*\(\)' | grep -v 'await'**Signal**:
server.setRequestHandler(CallToolRequestSchema, (request) => {
// Not async, not returning promise properly
this.searchIndex(request.params.arguments?.query).then((results) => {
return { content: [{ type: 'text', text: JSON.stringify(results) }] };
});
// Returns undefined! Handler completes before search finishes.
});**Why this matters**: The ha
Essays and writing behind this toolkit live at vexjoy.com. AI agents skip steps. "Looks correct" replaces running tests. "Trivial change" replaces verification.
Repo: notque/vexjoy-agent
Other agents on vexjoy-agent.
- ansible-automation-engineer
Ansible automation: playbooks, roles, collections, Molecule testing, Vault security.
Open agent - modules
**Scope**: Module selection patterns, builtin vs command/shell decisions, collection modules, and version-specific module changes **Version range**: ansible-core 2.14+ / Ansible Collections (community.general 7.0+) **Generated**: 2026-04-04 — verify against current Ansible
Open agent - testing
**Scope**: Molecule test scenarios, ansible-lint rules, idempotency validation, and check-mode patterns **Version range**: Molecule 6.0+ / ansible-lint 6.0+ / ansible-core 2.14+ **Generated**: 2026-04-04 — verify against current Molecule and ansible-lint documentation
Open agent - base-instructions
Universal operational rules injected by /do at agent dispatch. Domain-specific rules live in each agent's .md file.
Open agent - communication-patterns
**Scope**: Failure modes in agent output style — over-reporting, self-congratulation, verbose narration, and hedging. Covers what to detect and how to fix each. **Version range**: all versions **Generated**: 2026-05-11
Open agent - combat-effects-upgrade
Zero-dependency combat visual upgrades: CSS particle replacement, Framer Motion combat juice, CSS 3D card transforms.
Open agent

