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
  annotations?: {             // MCP tool annotations (hints for clients)
    readOnlyHint?: boolean;
    destructiveHint?: boolean;
    idempotentHint?: boolean;
    openWorldHint?: boolean;
  };
  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
  elicit?: (message: string, schema: AirElicitSchema) => Promise<AirElicitResult>;
                              // Request additional user input (undefined if client doesn't support it)
}

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' },
  ],
})

Tool Annotations

Annotations tell MCP clients about a tool's behavior before it runs. Clients use these hints for UI decisions (e.g., auto-approve read-only tools, require confirmation for destructive ones).

typescript
defineTool('delete-user', {
  description: 'Delete a user by ID',
  params: { userId: 'string' },
  annotations: {
    readOnlyHint: false,
    destructiveHint: true,
    idempotentHint: true,
    openWorldHint: false,
  },
  handler: async ({ userId }) => {
    await db.users.delete(userId);
    return `User ${userId} deleted.`;
  },
});

Available annotations

AnnotationTypeDefaultDescription
readOnlyHintbooleanundefinedTool only reads data, no side effects
destructiveHintbooleanundefinedTool may delete or permanently modify data
idempotentHintbooleanundefinedCalling multiple times with same params has same effect as once
openWorldHintbooleanundefinedTool interacts with external systems (APIs, network)

These are hints, not enforcement — the MCP protocol does not validate them. But well-annotated tools give clients better context for approval flows.

Elicitation

Elicitation lets a tool request additional input from the user mid-execution. The tool pauses, the client shows a prompt, and the user's response is returned to the handler.

typescript
defineTool('send-email', {
  description: 'Send an email',
  params: { to: 'string', subject: 'string', body: 'string' },
  handler: async (params, ctx) => {
    // ctx.elicit is undefined if the client doesn't support elicitation
    if (!ctx.elicit) {
      return 'This client does not support confirmation prompts.';
    }

    const confirmation = await ctx.elicit(
      `Send email to ${params.to}?`,
      { confirm: { type: 'boolean', description: 'Confirm send' } }
    );

    if (!confirmation || !confirmation.confirm) {
      return 'Email cancelled.';
    }

    await sendEmail(params);
    return `Email sent to ${params.to}.`;
  },
});

Elicit schema

The second argument to ctx.elicit() defines the input fields shown to the user:

typescript
await ctx.elicit('Please provide details', {
  name: { type: 'string', description: 'Your name' },
  age: { type: 'number', description: 'Your age' },
  agree: { type: 'boolean', description: 'Accept terms' },
});

Handling unsupported clients

Not all MCP clients support elicitation. Always guard:

typescript
handler: async (params, ctx) => {
  if (!ctx.elicit) return 'Elicitation not supported.';
  // ...
}

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.