도구
도구는 MCP 클라이언트(예: Claude)가 서버와 상호작용하는 주요 수단입니다. defineTool로 정의합니다.
도구란?
MCP에서 도구(Tool)는 AI가 작업을 수행하기 위해 호출하는 함수입니다. 데이터를 조회만 하는 리소스와 달리, 도구는 부수 효과(side effect)를 가질 수 있습니다.
- 도구: DB에 데이터 쓰기, 이메일 전송, API 호출, 파일 생성
- 리소스: 설정 읽기, 상태 조회 — 리소스 가이드 참고
기본 도구
import { defineTool } from '@airmcp-dev/core';
const greet = defineTool('greet', {
description: '인사하기',
params: { name: 'string' },
handler: async ({ name }) => `안녕하세요, ${name}!`,
});defineTool API
function defineTool(name: string, options: {
description?: string; // 설명 (LLM이 도구 선택 시 참고)
params?: AirToolParams; // 파라미터 정의
handler: AirToolHandler; // 핸들러 함수
annotations?: { // MCP 도구 어노테이션 (클라이언트 힌트)
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
};
layer?: number; // L1-L7 Meter 분류 힌트
tags?: string[]; // 분류/필터 태그
}): AirToolDef;description 작성 팁
description은 LLM이 도구를 선택할 때 참고하는 유일한 텍스트입니다. 잘 쓰면 AI가 올바른 도구를 더 정확하게 선택합니다.
// ❌ 너무 짧음 — AI가 언제 이 도구를 써야 하는지 판단 어려움
defineTool('search', {
description: '검색',
// ...
});
// ✅ 구체적 — AI가 용도를 정확히 이해
defineTool('search', {
description: '키워드로 문서를 검색합니다. 제목과 본문을 대상으로 전문 검색하며, 결과는 관련도 순으로 정렬됩니다.',
// ...
});파라미터 타입
세 가지 방식으로 파라미터를 정의할 수 있습니다. 세 형식은 같은 params 객체에서 혼합 사용 가능합니다.
단축 문자열
가장 간단합니다. ?를 붙이면 선택 사항.
params: {
query: 'string', // 필수 문자열
limit: 'number?', // 선택 숫자
verbose: 'boolean?', // 선택 불리언
metadata: 'object', // 필수 객체
options: 'object?', // 선택 객체
}사용 가능한 단축 표기:
| 단축 표기 | 변환되는 Zod 스키마 |
|---|---|
'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() |
객체 형식
설명과 선택 여부를 명시적으로 제어합니다. description은 MCP 클라이언트가 파라미터 설명으로 사용합니다.
params: {
query: { type: 'string', description: '검색어' },
limit: { type: 'number', description: '최대 결과 수', optional: true },
includeArchived: { type: 'boolean', description: '보관된 항목 포함', optional: true },
}객체 형식의 description은 Zod의 .describe()로 변환됩니다.
Zod 스키마
복잡한 검증이 필요할 때 Zod를 직접 사용합니다.
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']),
}혼합 사용
params: {
query: 'string', // 단축
limit: { type: 'number', description: '최대', optional: true }, // 객체
tags: z.array(z.string()).optional(), // Zod
}파라미터 없는 도구
params를 생략하면 파라미터 없이 호출 가능한 도구가 됩니다:
defineTool('now', {
description: '현재 시각 반환',
handler: async () => new Date().toISOString(),
});핸들러 함수
핸들러는 두 인자를 받습니다: 파싱된 파라미터와 컨텍스트 객체.
handler: async (params, context) => {
console.log(context.requestId); // 고유 요청 ID (UUID v4)
console.log(context.serverName); // 서버 이름
console.log(context.startedAt); // 타임스탬프 (ms)
console.log(context.state); // 글로벌 서버 상태
return 'result';
}AirToolContext
interface AirToolContext {
requestId: string; // crypto.randomUUID()로 생성
serverName: string; // defineServer의 name
startedAt: number; // Date.now()
state: Record<string, any>; // server.state와 동일 참조
elicit?: (message: string, schema: AirElicitSchema) => Promise<AirElicitResult>;
// 사용자 추가 입력 요청 (클라이언트 미지원 시 undefined)
}동기/비동기
핸들러는 async 함수(Promise 반환)와 동기 함수 모두 가능합니다:
// 비동기
handler: async ({ query }) => {
const results = await db.search(query);
return results;
}
// 동기
handler: ({ a, b }) => a + b응답 타입
핸들러는 다양한 타입을 반환할 수 있습니다. air의 normalizeResult 함수가 자동으로 MCP content 형식으로 변환합니다.
변환 규칙
| 반환값 | MCP 변환 결과 |
|---|---|
string | [{ type: 'text', text: '...' }] |
number | [{ type: 'text', text: '42' }] (String 변환) |
boolean | [{ type: 'text', text: 'true' }] (String 변환) |
null / undefined | [{ type: 'text', text: '' }] (빈 텍스트) |
Array | [{ type: 'text', text: '...' }] (JSON.stringify, pretty) |
Object | [{ type: 'text', text: '...' }] (JSON.stringify, pretty) |
{ text: string } | [{ type: 'text', text: '...' }] |
{ image: string, mimeType?: string } | [{ type: 'image', data: '...', mimeType: '...' }] |
{ content: McpContent[] } | 그대로 반환 (이미 MCP 형식) |
예제
// 문자열
handler: async () => 'hello'
// 숫자 → 문자열로 변환
handler: async () => 42
// null → 빈 텍스트
handler: async () => null
// 객체 → JSON (들여쓰기 포함)
handler: async () => ({ name: 'Alice', age: 30 })
// MCP content: [{ type: 'text', text: '{\n "name": "Alice",\n "age": 30\n}' }]
// 배열 → JSON (들여쓰기 포함)
handler: async () => [1, 2, 3]
// MCP content: [{ type: 'text', text: '[\n 1,\n 2,\n 3\n]' }]
// 명시적 텍스트
handler: async () => ({ text: '포맷팅된 결과' })
// 이미지
handler: async () => ({
image: 'base64-encoded-png-data',
mimeType: 'image/png',
})
// 이미 MCP 형식 — 복수 content 블록
handler: async () => ({
content: [
{ type: 'text', text: '이미지 설명:' },
{ type: 'image', data: 'base64...', mimeType: 'image/png' },
],
})도구 어노테이션
어노테이션은 도구의 동작을 MCP 클라이언트에게 실행 전에 알려줍니다. 클라이언트는 이 힌트를 UI 결정에 활용합니다 (예: 읽기 전용 도구는 자동 승인, 파괴적 도구는 확인 요청).
defineTool('delete-user', {
description: '사용자를 ID로 삭제',
params: { userId: 'string' },
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
handler: async ({ userId }) => {
await db.users.delete(userId);
return `사용자 ${userId} 삭제 완료.`;
},
});사용 가능한 어노테이션
| 어노테이션 | 타입 | 기본값 | 설명 |
|---|---|---|---|
readOnlyHint | boolean | undefined | 데이터만 읽고 부작용 없음 |
destructiveHint | boolean | undefined | 데이터를 삭제하거나 영구 수정할 수 있음 |
idempotentHint | boolean | undefined | 같은 파라미터로 여러 번 호출해도 효과가 동일 |
openWorldHint | boolean | undefined | 외부 시스템(API, 네트워크)과 상호작용 |
힌트일 뿐 강제되지 않습니다 — MCP 프로토콜이 이를 검증하지 않습니다. 하지만 잘 설정된 어노테이션은 클라이언트에게 승인 플로우에 대한 더 나은 맥락을 제공합니다.
Elicitation (사용자 추가 입력 요청)
Elicitation은 도구가 실행 중 사용자에게 추가 입력을 요청할 수 있게 합니다. 도구가 일시 정지하고, 클라이언트가 프롬프트를 보여주고, 사용자의 응답이 핸들러에 반환됩니다.
defineTool('send-email', {
description: '이메일 전송',
params: { to: 'string', subject: 'string', body: 'string' },
handler: async (params, ctx) => {
// 클라이언트가 elicitation을 지원하지 않으면 ctx.elicit는 undefined
if (!ctx.elicit) {
return '이 클라이언트는 확인 프롬프트를 지원하지 않습니다.';
}
const confirmation = await ctx.elicit(
`${params.to}에게 이메일을 보낼까요?`,
{ confirm: { type: 'boolean', description: '전송 확인' } }
);
if (!confirmation || !confirmation.confirm) {
return '이메일 취소됨.';
}
await sendEmail(params);
return `${params.to}에게 이메일 전송 완료.`;
},
});Elicit 스키마
ctx.elicit()의 두 번째 인수는 사용자에게 보여줄 입력 필드를 정의합니다:
await ctx.elicit('세부 정보를 입력해주세요', {
name: { type: 'string', description: '이름' },
age: { type: 'number', description: '나이' },
agree: { type: 'boolean', description: '약관 동의' },
});미지원 클라이언트 처리
모든 MCP 클라이언트가 elicitation을 지원하지는 않습니다. 항상 가드하세요:
handler: async (params, ctx) => {
if (!ctx.elicit) return 'Elicitation을 지원하지 않습니다.';
// ...
}계층 힌트
layer 속성으로 Meter 분류에 힌트를 줍니다:
defineTool('cache-lookup', {
layer: 1, // L1: 캐시 히트, 비용 거의 없음
handler: async ({ key }) => cache.get(key),
});
defineTool('db-query', {
layer: 3, // L3: 필터 쿼리
handler: async ({ sql }) => db.query(sql),
});
defineTool('ai-summarize', {
layer: 6, // L6: LLM 호출, 토큰 소모
handler: async ({ text }) => llm.summarize(text),
});
defineTool('ai-agent', {
layer: 7, // L7: 에이전트 체인, 높은 비용
handler: async ({ task }) => agentLoop(task),
});layer를 생략하면 Meter가 실행 시간 기반으로 자동 분류합니다.
태그
태그는 도구를 분류하고 필터링하기 위한 메타데이터입니다:
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'));
const userTools = server.tools().filter(t => t.tags?.includes('user'));런타임 도구 추가
server.addTool(defineTool('dynamic-tool', {
description: '런타임에 추가된 도구',
handler: async () => 'works!',
}));addTool은 플러그인의 onToolRegister 훅을 거칩니다.
헬퍼 함수
paramsToZodSchema
단축 파라미터를 Zod 스키마로 변환합니다. 내부적으로 .passthrough()가 적용되어 정의되지 않은 키도 통과합니다.
import { paramsToZodSchema } from '@airmcp-dev/core';
const schema = paramsToZodSchema({
query: 'string',
limit: 'number?',
});
// → z.object({ query: z.string(), limit: z.number().optional() }).passthrough()
// params가 없거나 빈 객체면 undefined 반환
paramsToZodSchema(undefined); // → undefined
paramsToZodSchema({}); // → undefinedparamsToJsonSchema
MCP 도구 등록용 JSON Schema로 변환합니다:
import { paramsToJsonSchema } from '@airmcp-dev/core';
const jsonSchema = paramsToJsonSchema({
query: { type: 'string', description: '검색어' },
limit: 'number?',
});
// → {
// type: 'object',
// properties: {
// query: { type: 'string' },
// limit: { type: 'number' },
// },
// required: ['query']
// }normalizeResult
핸들러 반환값을 MCP content 배열로 변환합니다:
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]' }]
normalizeResult({ image: 'base64...', mimeType: 'image/png' });
// → [{ type: 'image', data: 'base64...', mimeType: 'image/png' }]