I've been building MCP servers for a few months now. Every time, same pattern — write a Zod schema, wrap the result in
{ content: [{ type: 'text', text: JSON.stringify(result) }] },
add try/catch, set up the transport. Repeat for every tool.
It's not hard, but it adds up.
I counted once: about 25 lines per tool with the official SDK — and once you add retry, caching, or auth, easily 60-70 lines. Most of that isn't actual logic. So I built a framework on top of it. It's called air and it's open source (Apache-2.0).
Before and After
With the MCP SDK:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({ name: 'my-server', version: '0.1.0' });
server.tool(
'search',
'Search documents',
{ query: z.string(), limit: z.number().optional() },
async ({ query, limit }) => {
try {
const results = await doSearch(query, limit);
return {
content: [{ type: 'text', text: JSON.stringify(results) }],
};
} catch (error) {
return {
content: [{ type: 'text', text: `Error: ${error.message}` }],
isError: true,
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
With air:
import { defineServer, defineTool, retryPlugin, cachePlugin } from '@airmcp-dev/core';
const server = defineServer({
name: 'my-server',
transport: { type: 'sse', port: 3510 },
use: [
retryPlugin({ maxRetries: 3 }),
cachePlugin({ ttlMs: 60_000 }),
],
tools: [
defineTool('search', {
description: 'Search documents',
params: { query: 'string', limit: 'number?' },
handler: async ({ query, limit }) => doSearch(query, limit),
}),
],
});
server.start();
The second one includes retry and caching. The first one doesn't.
What it does differently
No Zod boilerplate.
Write 'string' instead of z.string().
Optional params? 'number?'. air generates the Zod and JSON schemas internally.
No content wrapping.
Just return whatever you want — string, number, object, array.
air converts it to the MCP content format.
Plugins instead of manual middleware.
There are 19 built-in plugins.
You add them to the use array and they run in order:
use: [
authPlugin({ type: 'api-key', keys: [process.env.MCP_API_KEY!] }),
sanitizerPlugin(),
timeoutPlugin(10_000),
retryPlugin({ maxRetries: 3 }),
cachePlugin({ ttlMs: 60_000 }),
]
No need to implement retry logic, caching, auth, or rate limiting yourself.
Built-in storage.
const store = await createStorage({ type: 'file', path: '.air/data' });
await store.set('users', 'u1', { name: 'Alice' }, 3600);
await store.append('logs', { action: 'login' });
await store.query('logs', { limit: 50, filter: { action: 'login' } });
MemoryStore and FileStore included. TTL support.
Append-only logs with query and filter.
I tested it with a local LLM
Connected air to llama3.1 via Ollama.
Set up 4 tools — system info, notes, calculator, metrics.
Asked natural language questions.
Results:
"What's the CPU and memory?" → called system_info
"Create a note called Meeting Notes" → called note_create with correct
params
"What's 1234 times 5678?" → called calc, got 7,006,652
"Show my saved notes" → called note_list
"What is MCP?" → no tool call, answered from its own knowledge
All calls went through the plugin pipeline
(sanitizer → timeout → retry → cache) automatically.
What's included
19 plugins: timeout, retry, circuit breaker, fallback, cache, dedup, queue, auth, sanitizer, validator, cors, webhook, transform, i18n, json logger, per-user rate limit, dryrun
3 transports: stdio, SSE, Streamable HTTP
Storage: MemoryStore, FileStore
7-Layer Meter: classify calls from L1 (cache hit) to L7 (multi-step agent chain) for cost tracking
CLI: project scaffolding, dev mode with hot reload, client registration for Claude Desktop / Cursor / VS Code
Gateway: multi-server proxy with load balancing and health checks
Status
5 packages on npm, all Apache-2.0
165 tests passing
0 security vulnerabilities
(checked all 93 dependencies)
110-page docs in English and Korean
Try it
npm install @airmcp-dev/core
import { defineServer, defineTool } from '@airmcp-dev/core';
const server = defineServer({
name: 'hello',
tools: [
defineTool('greet', {
params: { name: 'string' },
handler: async ({ name }) => `Hello, ${name}!`,
}),
],
});
server.start();
This is my first time sharing something I built.
I'd genuinely appreciate any feedback — what looks useful,
what's missing, what you'd do differently. Thanks for reading.
Top comments (0)