Skip to content

Tools

Tools are the primary way MCP clients (like Claude) interact with your server. Define them with defineTool.

What is a tool?

In MCP, a Tool is a function that AI calls to perform an action. Unlike Resources (read-only data), tools can have side effects.

  • Tools: write to DB, send email, call API, create files
  • Resources: read config, check status, view files — see Resources guide

Basic tool

typescript
import { defineTool } from '@airmcp-dev/core';

const greet = defineTool('greet', {
  description: 'Say hello',
  params: { name: 'string' },
  handler: async ({ name }) => `Hello, ${name}!`,
});

defineTool API

typescript
function defineTool(name: string, options: {
  description?: string;       // Description (LLM uses this to select the tool)
  params?: AirToolParams;     // Parameter definitions
  handler: AirToolHandler;    // Handler function
  layer?: number;             // L1-L7 Meter classification hint
  tags?: string[];            // Tags for filtering
}): AirToolDef;

Writing good descriptions

description is the only text LLM uses to decide when to call your tool. Better descriptions → more accurate tool selection.

typescript
// ❌ Too vague — AI can't tell when to use this
defineTool('search', {
  description: 'Search',
  // ...
});

// ✅ Specific — AI understands the purpose
defineTool('search', {
  description: 'Search documents by keyword. Full-text search across titles and body, results sorted by relevance.',
  // ...
});

Parameter types

Three ways to define parameters. All three can be mixed in the same params object.

Shorthand strings

Simplest. Append ? for optional.

typescript
params: {
  query: 'string',           // required string
  limit: 'number?',          // optional number
  verbose: 'boolean?',       // optional boolean
  metadata: 'object',        // required object
  options: 'object?',        // optional object
}

Shorthand to Zod conversion:

ShorthandZod Schema
'string'z.string()
'string?'z.string().optional()
'number'z.number()
'number?'z.number().optional()
'boolean'z.boolean()
'boolean?'z.boolean().optional()
'object'z.record(z.any())
'object?'z.record(z.any()).optional()

Object format

Explicit control over description and optional flag. The description is used by MCP clients as parameter help text.

typescript
params: {
  query: { type: 'string', description: 'Search query' },
  limit: { type: 'number', description: 'Max results', optional: true },
  includeArchived: { type: 'boolean', description: 'Include archived items', optional: true },
}

Object format description is converted to Zod's .describe().

Zod schemas

Full Zod power for complex validation.

typescript
import { z } from 'zod';

params: {
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  tags: z.array(z.string()).optional(),
  role: z.enum(['admin', 'user', 'guest']),
}

Mixing formats

typescript
params: {
  query: 'string',                                    // shorthand
  limit: { type: 'number', description: 'Max', optional: true },  // object
  tags: z.array(z.string()).optional(),                // Zod
}

Tools without params

Omit params to create a tool callable without arguments:

typescript
defineTool('now', {
  description: 'Return current time',
  handler: async () => new Date().toISOString(),
});

Handler function

The handler receives two arguments: parsed params and a context object.

typescript
handler: async (params, context) => {
  console.log(context.requestId);    // UUID v4 (crypto.randomUUID())
  console.log(context.serverName);   // Server name
  console.log(context.startedAt);    // Timestamp (ms)
  console.log(context.state);        // Global server state
  return 'result';
}

AirToolContext

typescript
interface AirToolContext {
  requestId: string;          // Generated via crypto.randomUUID()
  serverName: string;         // From defineServer name
  startedAt: number;          // Date.now()
  state: Record<string, any>; // Same reference as server.state
}

Sync and async

Handlers can be async (return Promise) or synchronous:

typescript
// Async
handler: async ({ query }) => {
  const results = await db.search(query);
  return results;
}

// Sync
handler: ({ a, b }) => a + b

Response types

Handlers can return various types. air's normalizeResult function auto-converts them to MCP content format.

Conversion rules

Return valueMCP result
string[{ type: 'text', text: '...' }]
number[{ type: 'text', text: '42' }] (String conversion)
boolean[{ type: 'text', text: 'true' }] (String conversion)
null / undefined[{ type: 'text', text: '' }] (empty text)
Array[{ type: 'text', text: '...' }] (JSON.stringify, pretty)
Object[{ type: 'text', text: '...' }] (JSON.stringify, pretty)
{ text: string }[{ type: 'text', text: '...' }]
{ image: string, mimeType? }[{ type: 'image', data: '...', mimeType: '...' }]
{ content: McpContent[] }Passed through as-is (already MCP format)

Examples

typescript
// String
handler: async () => 'hello'

// Number → string
handler: async () => 42

// null → empty text
handler: async () => null

// Object → pretty JSON
handler: async () => ({ name: 'Alice', age: 30 })
// → [{ type: 'text', text: '{\n  "name": "Alice",\n  "age": 30\n}' }]

// Array → pretty JSON
handler: async () => [1, 2, 3]
// → [{ type: 'text', text: '[\n  1,\n  2,\n  3\n]' }]

// Explicit text
handler: async () => ({ text: 'formatted result' })

// Image
handler: async () => ({
  image: 'base64-encoded-png-data',
  mimeType: 'image/png',
})

// Already MCP format — multiple content blocks
handler: async () => ({
  content: [
    { type: 'text', text: 'Image description:' },
    { type: 'image', data: 'base64...', mimeType: 'image/png' },
  ],
})

Layer hints

The layer property hints the Meter classifier:

typescript
defineTool('cache-lookup', { layer: 1, handler: async ({ key }) => cache.get(key) });
defineTool('db-query', { layer: 3, handler: async ({ sql }) => db.query(sql) });
defineTool('ai-summarize', { layer: 6, handler: async ({ text }) => llm.summarize(text) });
defineTool('ai-agent', { layer: 7, handler: async ({ task }) => agentLoop(task) });

Omit layer and Meter auto-classifies based on execution time.

Tags

Metadata for grouping and filtering:

typescript
defineTool('user-search', { tags: ['user', 'read'], handler: async ({ query }) => db.users.find(query) });
defineTool('user-create', { tags: ['user', 'write'], handler: async ({ data }) => db.users.create(data) });

const readTools = server.tools().filter(t => t.tags?.includes('read'));

Runtime tool addition

typescript
server.addTool(defineTool('dynamic-tool', {
  description: 'Added at runtime',
  handler: async () => 'works!',
}));

addTool passes through plugin onToolRegister hooks.

Helpers

paramsToZodSchema

Converts shorthand params to a Zod schema. Internally applies .passthrough() so undefined keys pass through.

typescript
import { paramsToZodSchema } from '@airmcp-dev/core';

const schema = paramsToZodSchema({ query: 'string', limit: 'number?' });
// → z.object({ query: z.string(), limit: z.number().optional() }).passthrough()

paramsToZodSchema(undefined);  // → undefined
paramsToZodSchema({});          // → undefined

paramsToJsonSchema

Converts to JSON Schema for MCP tool registration:

typescript
import { paramsToJsonSchema } from '@airmcp-dev/core';

const jsonSchema = paramsToJsonSchema({
  query: { type: 'string', description: 'Search query' },
  limit: 'number?',
});
// → { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] }

normalizeResult

Converts handler return values to MCP content array:

typescript
import { normalizeResult } from '@airmcp-dev/core';

normalizeResult('hello');           // → [{ type: 'text', text: 'hello' }]
normalizeResult(42);                // → [{ type: 'text', text: '42' }]
normalizeResult(null);              // → [{ type: 'text', text: '' }]
normalizeResult({ name: 'Alice' }); // → [{ type: 'text', text: '{\n  "name": "Alice"\n}' }]
normalizeResult([1, 2, 3]);         // → [{ type: 'text', text: '[\n  1,\n  2,\n  3\n]' }]

Released under the Apache-2.0 License.