TypeScript로 MCP 서버 직접 만들기: 2026 완벽 가이드
2026년 쓸 만한 AI IDE는 전부 MCP를 지원해요. Claude Desktop, Cursor, Windsurf, VS Code Copilot까지. Model Context Protocol이 AI 어시스턴트를 외부 도구·데이터베이스·API와 연결하는 사실상의 표준이 됐거든요. 그런데 대부분의 개발자는 아직 남이 만든 MCP 서버를 쓰기만 하지, 직접 만들지는 않아요.
기회를 놓치고 있는 거예요. MCP 서버를 직접 만들면 AI 코딩 어시스턴트에 뭐든 연결할 수 있거든요. 회사 내부 API, 배포 파이프라인, 데이터베이스, 모니터링 대시보드, CMS까지 전부요. 첫 MCP 서버를 만드는 순간, AI 어시스턴트가 범용 챗봇에서 개발 환경의 진짜 확장이 돼요.
이 글에서는 TypeScript로 프로덕션 품질의 MCP 서버를 만드는 전 과정을 다뤄요. 제로부터 시작해서 프로덕션 레디까지. 프로토콜 내부 구조, 공식 SDK, 실전 패턴, 보안, 배포 전략까지 빠짐없이요.
MCP가 뭔지 30초 만에 정리
MCP 서버를 써봤지만 만들어본 적은 없다면, 이렇게 이해하면 돼요:
MCP는 JSON-RPC 2.0 기반 프로토콜이에요. AI 클라이언트(Claude, Cursor 등)와 외부 기능 제공자(내 서버) 간 통신을 표준화한 거예요. 코드 에디터용 LSP(Language Server Protocol) 알죠? 그거랑 비슷한데, 대상이 에디터가 아니라 AI 어시스턴트인 거예요.
MCP 서버가 노출할 수 있는 기능은 세 가지예요:
- Tools (도구) : AI가 호출할 수 있는 함수 (예: "DB 쿼리 실행", "스테이징에 배포", "Jira 티켓 생성")
- Resources (리소스) : AI가 읽을 수 있는 데이터 (예: "현재 스키마", "오늘 에러 로그", "배포 상태")
- Prompts (프롬프트) : AI가 쓸 수 있는 사전 정의 템플릿 (예: "이 PR 분석해줘", "마이그레이션 계획 만들어줘")
클라이언트(Claude, Cursor)가 서버의 기능 목록을 발견하고, AI 모델이 대화 맥락에 따라 언제·어떻게 쓸지 결정하는 구조예요.
┌─────────────────┐ JSON-RPC 2.0 ┌─────────────────┐
│ AI 클라이언트 │ ◄──────────────────────────► │ MCP 서버 │
│ (Claude 등) │ stdio / SSE / HTTP │ (내 코드) │
│ │ │ │
│ - 기능 발견 │ │ - Tools │
│ - Tool 호출 │ │ - Resources │
│ - 데이터 읽기 │ │ - Prompts │
└─────────────────┘ └─────────────────┘
프로젝트 세팅
실제 MCP 서버를 만들어볼게요. PostgreSQL 데이터베이스에 AI 어시스턴트가 접근할 수 있게 해주는 서버예요. 핵심 패턴을 전부 보여주는 실용적인 사례죠.
사전 준비
node --version # v20+ 권장 npm --version # v10+
프로젝트 초기화
mkdir mcp-database-server cd mcp-database-server npm init -y npm install @modelcontextprotocol/sdk@latest zod # 2026년 3월 기준 v1.27+ npm install -D typescript @types/node tsx
TypeScript 설정:
// tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "declaration": true }, "include": ["src/**/*"] }
package.json 업데이트:
{ "type": "module", "bin": { "mcp-database-server": "./dist/index.js" }, "scripts": { "build": "tsc", "dev": "tsx src/index.ts", "start": "node dist/index.js" } }
첫 번째 MCP 서버 만들기
기본 뼈대부터 볼게요. 모든 MCP 서버의 시작은 같아요:
// src/index.ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; const server = new McpServer({ name: 'mcp-database-server', version: '1.0.0', capabilities: { tools: {}, resources: {}, prompts: {}, }, }); // 여기에 Tools, Resources, Prompts를 추가할 거예요 // stdio 트랜스포트로 연결 const transport = new StdioServerTransport(); await server.connect(transport); console.error('MCP Database Server running on stdio');
로그를 stderr로 찍는 거 주목하세요. stdout 채널은 클라이언트와 JSON-RPC 통신 전용이에요. stdout에 JSON-RPC가 아닌 걸 쓰면 프로토콜이 깨져요. MCP 서버 처음 만들 때 다들 한 번씩 겪는 실수예요.
Tools 추가하기
Tools는 MCP에서 가장 강력한 프리미티브예요. AI가 실제 동작을 대신 수행할 수 있게 해주거든요.
기본 Tool 정의
import { z } from 'zod'; server.tool( 'query', 'Execute a read-only SQL query against the database', { sql: z.string().describe('The SQL query to execute (SELECT only)'), params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])) .optional() .describe('Parameterized query values'), }, async ({ sql, params }) => { // 읽기 전용 쿼리인지 검증 const normalized = sql.trim().toUpperCase(); if (!normalized.startsWith('SELECT') && !normalized.startsWith('WITH') && !normalized.startsWith('EXPLAIN')) { return { content: [{ type: 'text', text: 'Error: Only SELECT, WITH, and EXPLAIN queries are allowed.', }], isError: true, }; } try { const result = await pool.query(sql, params); return { content: [{ type: 'text', text: JSON.stringify(result.rows, null, 2), }], }; } catch (error) { return { content: [{ type: 'text', text: `Query error: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, }; } } );
구조를 뜯어볼게요:
- 이름 (
'query') : Tool의 고유 식별자 - 설명 : 정말 중요해요. AI가 이걸 읽고 언제 이 Tool을 쓸지 판단하거든요. 똑똑한 주니어한테 설명하듯 쓰면 돼요.
- 스키마 : 입력 파라미터를 정의하는 Zod 스키마. AI가 이걸 보고 올바른 호출을 구성해요.
- 핸들러 : Tool이 호출될 때 실행되는 async 함수.
Tool 설명 잘 쓰는 법
Tool 설명은 사실상 프롬프트 엔지니어링이에요. AI 모델이 이걸 읽고 어떤 Tool을 쓸지, 파라미터는 어떻게 넣을지 결정하거든요. 설명이 부실하면 AI가 엉뚱한 Tool을 고르거나 파라미터를 잘못 넣어요.
// ❌ 나쁜 예: 너무 모호함 server.tool('query', 'Run a query', { sql: z.string() }, handler); // ❌ 나쁜 예: 너무 짧고 제약 조건 없음 server.tool('query', 'SQL query', { sql: z.string() }, handler); // ✅ 좋은 예: 구체적이고 제약 조건과 예시 포함 server.tool( 'query', 'Execute a read-only SQL query against the PostgreSQL database. ' + 'Only SELECT, WITH (CTE), and EXPLAIN queries are allowed. ' + 'Use parameterized queries ($1, $2, ...) for user-provided values. ' + 'Returns results as JSON array of objects. ' + 'Example: SELECT id, name FROM users WHERE active = $1', { sql: z.string().describe('PostgreSQL query (SELECT/WITH/EXPLAIN only)'), params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])) .optional() .describe('Values for parameterized query placeholders ($1, $2, ...)'), }, handler );
멀티 Tool 패턴
실제 서버는 연관된 Tool을 여러 개 노출해요. CRUD 작업별로 적절한 가드레일을 달아서요:
// 읽기 전용: 확인 불필요 server.tool( 'list_tables', 'List all tables in the database with their column count and row count estimates', {}, async () => { const result = await pool.query(` SELECT schemaname, tablename, (SELECT count(*) FROM information_schema.columns c WHERE c.table_schema = t.schemaname AND c.table_name = t.tablename) as column_count, n_live_tup as estimated_rows FROM pg_stat_user_tables t ORDER BY schemaname, tablename `); return { content: [{ type: 'text', text: JSON.stringify(result.rows, null, 2) }], }; } ); // 파라미터 있는 읽기 전용 server.tool( 'describe_table', 'Get detailed schema information for a specific table including columns, types, constraints, and indexes', { table: z.string().describe('Table name (can include schema prefix like "public.users")'), }, async ({ table }) => { const [schema, tableName] = table.includes('.') ? table.split('.') : ['public', table]; const columns = await pool.query(` SELECT column_name, data_type, is_nullable, column_default, character_maximum_length FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position `, [schema, tableName]); const indexes = await pool.query(` SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 AND tablename = $2 `, [schema, tableName]); return { content: [{ type: 'text', text: JSON.stringify({ columns: columns.rows, indexes: indexes.rows }, null, 2), }], }; } ); // 쓰기 작업: AI가 사용자에게 확인받도록 결과를 명확히 반환 server.tool( 'execute_migration', 'Execute a SQL migration statement (CREATE, ALTER, DROP). ' + 'WARNING: This modifies the database schema. Use with caution. ' + 'The AI should always confirm with the user before calling this tool.', { sql: z.string().describe('The DDL statement to execute'), description: z.string().describe('Human-readable description of what this migration does'), }, async ({ sql, description }) => { try { await pool.query('BEGIN'); await pool.query(sql); await pool.query('COMMIT'); return { content: [{ type: 'text', text: `Migration successful: ${description}\n\nExecuted:\n${sql}`, }], }; } catch (error) { await pool.query('ROLLBACK'); return { content: [{ type: 'text', text: `Migration failed: ${error instanceof Error ? error.message : 'Unknown error'}\n\nRolled back.`, }], isError: true, }; } } );
리소스(Resources) 추가하기
리소스는 AI가 동작을 실행하지 않고 데이터를 읽을 수 있게 해요. 맥락(context) 제공에 아주 좋아요. AI가 더 나은 판단을 하는 데 필요한 배경 정보를 주는 거죠.
정적 리소스
server.resource( 'schema', 'db://schema', { description: 'Current database schema including all tables, columns, and relationships', mimeType: 'application/json', }, async () => { const result = await pool.query(` SELECT t.table_schema, t.table_name, json_agg(json_build_object( 'column', c.column_name, 'type', c.data_type, 'nullable', c.is_nullable = 'YES', 'default', c.column_default ) ORDER BY c.ordinal_position) as columns FROM information_schema.tables t JOIN information_schema.columns c ON c.table_schema = t.table_schema AND c.table_name = t.table_name WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' GROUP BY t.table_schema, t.table_name ORDER BY t.table_name `); return { contents: [{ uri: 'db://schema', mimeType: 'application/json', text: JSON.stringify(result.rows, null, 2), }], }; } );
리소스 템플릿 (동적 리소스)
리소스 템플릿으로 파라미터화된 데이터를 노출할 수 있어요:
server.resource( 'table-data', 'db://tables/{tableName}/sample', { description: 'Sample data (first 10 rows) from a specific table', mimeType: 'application/json', }, async (uri) => { const tableName = uri.pathname.split('/')[2]; // 테이블 이름 유효성 검증 (SQL injection 방지) const validTable = await pool.query( `SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = $1`, [tableName] ); if (validTable.rows.length === 0) { throw new Error(`Table '${tableName}' not found`); } const result = await pool.query(`SELECT * FROM "${tableName}" LIMIT 10`); return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(result.rows, null, 2), }], }; } );
프롬프트(Prompts) 추가하기
프롬프트는 AI가 특정 작업을 수행할 때 쓸 수 있는 사전 정의 템플릿이에요. 많이들 안 쓰는데, 도메인 전문 지식을 인코딩하는 데 정말 강력해요.
server.prompt( 'analyze-query-performance', 'Analyze a slow SQL query and suggest optimizations', { query: z.string().describe('The SQL query to analyze'), }, async ({ query }) => { const explainResult = await pool.query(`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ${query}`); const plan = JSON.stringify(explainResult.rows[0], null, 2); return { messages: [ { role: 'user', content: { type: 'text', text: [ `다음 PostgreSQL 쿼리의 성능을 분석해주세요:`, '', '```sql', query, '```', '', '실행 계획 (EXPLAIN ANALYZE):', '```json', plan, '```', '', '다음 항목을 분석해주세요:', '1. 현재 실행 계획이 말해주는 것', '2. 병목 지점 (시퀀셜 스캔, 고비용 노드)', '3. CREATE INDEX 문과 함께 구체적인 인덱스 추천', '4. 쿼리 재작성 제안 (해당될 경우)', '5. 최적화 후 예상 개선 효과', ].join('\n'), }, }, ], }; } );
전송 계층: stdio, SSE, Streamable HTTP
MCP는 여러 전송 메커니즘을 지원해요. 배포 모델에 따라 맞는 걸 골라야 해요.
stdio (표준 I/O)
로컬 MCP 서버의 기본값이에요. 클라이언트가 서버를 자식 프로세스로 실행하고 stdin/stdout으로 통신해요.
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; const transport = new StdioServerTransport(); await server.connect(transport);
언제 써야 하나: 로컬 개발, CLI 연동, AI 클라이언트와 같은 머신에서 돌아가는 서버.
장점: 간단하고 네트워크 설정 불필요, 라이프사이클 자동 관리.
단점: 로컬에서만 작동하고 서버 인스턴스당 클라이언트 하나.
SSE (Server-Sent Events) : 지원 중단됨
⚠️ 참고: SSE 트랜스포트는 2025-11-25 MCP 스펙부터 지원 중단(deprecated) 됐어요. 새 프로젝트는 Streamable HTTP를 쓰세요. 기존 서버에서 아직 쓰이고 있어서 참고용으로 남겨둔 거예요.
HTTP로 접근 가능한 원격 서버용:
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import express from 'express'; const app = express(); const transports = new Map<string, SSEServerTransport>(); app.get('/sse', (req, res) => { const transport = new SSEServerTransport('/messages', res); transports.set(transport.sessionId, transport); res.on('close', () => transports.delete(transport.sessionId)); server.connect(transport); }); app.post('/messages', (req, res) => { const sessionId = req.query.sessionId as string; const transport = transports.get(sessionId); if (transport) transport.handlePostMessage(req, res); else res.status(404).send('Session not found'); }); app.listen(3001, () => console.error('MCP SSE server listening on port 3001'));
언제 써야 하나: 레거시 서버, 이전 클라이언트와의 하위 호환. 새 프로젝트는 Streamable HTTP를 쓰세요.
Streamable HTTP (권장)
2026년 기준 모든 새 원격 MCP 서버에 권장되는 트랜스포트예요. SSE를 대체해서 표준 웹 트랜스포트가 됐고, 단일 엔드포인트, 배치/스트리밍 응답 지원, 세션 관리, 수평 확장이 가능해요:
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express from 'express'; const app = express(); app.use(express.json()); app.post('/mcp', async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless 모드 }); res.on('close', () => transport.close()); await server.connect(transport); await transport.handleRequest(req, res); }); app.listen(3001);
언제 써야 하나: 서버리스 배포(AWS Lambda, Vercel Edge), 상태 없는 API, 장기 연결이 문제되는 환경.
보안: RCE 서버를 만들면 안 돼요
대부분의 MCP 튜토리얼이 여기서 끝나는데, 진짜 중요한 작업은 여기서부터예요. 보안이 허술한 MCP 서버는 AI 모델이 트리거할 수 있는 원격 코드 실행(RCE) 엔드포인트와 마찬가지거든요.
입력 검증
AI 모델의 입력을 절대 믿으면 안 돼요. 전부 검증하세요:
server.tool( 'query', 'Execute a read-only SQL query', { sql: z.string() .max(10000) // 쿼리 길이 제한 .refine( (sql) => { const upper = sql.trim().toUpperCase(); return upper.startsWith('SELECT') || upper.startsWith('WITH') || upper.startsWith('EXPLAIN'); }, { message: 'Only SELECT, WITH, and EXPLAIN queries are allowed' } ) .refine( (sql) => { const upper = sql.toUpperCase(); const dangerous = ['DROP', 'DELETE', 'UPDATE', 'INSERT', 'ALTER', 'TRUNCATE', 'GRANT', 'REVOKE', 'CREATE']; return !dangerous.some(keyword => new RegExp(`\\b${keyword}\\b`).test(upper) ); }, { message: 'Query contains disallowed DDL/DML keywords' } ), }, handler );
DB 연결 보안
쿼리 도구에는 읽기 전용 연결을 쓰세요:
import pg from 'pg'; // 읽기 전용 커넥션 풀 const readPool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5, options: '-c default_transaction_read_only=on', }); // 쓰기 작업용 별도 풀 (필요할 때만) const writePool = new pg.Pool({ connectionString: process.env.DATABASE_WRITE_URL, max: 2, });
레이트 리미팅
AI가 폭주하며 서버를 두들기는 걸 막아야 해요:
class RateLimiter { private calls: Map<string, number[]> = new Map(); check(toolName: string, maxCalls: number, windowMs: number): boolean { const now = Date.now(); const timestamps = this.calls.get(toolName) || []; const recent = timestamps.filter(t => now - t < windowMs); if (recent.length >= maxCalls) return false; recent.push(now); this.calls.set(toolName, recent); return true; } }
타임아웃 보호
function withTimeout<T>(promise: Promise<T>, ms: number, operation: string): Promise<T> { return Promise.race([ promise, new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`${operation} timed out after ${ms}ms`)), ms) ), ]); } // 도구 핸들러에서: const result = await withTimeout(pool.query(sql, params), 30_000, 'Database query');
에러 처리 패턴
토이 서버와 프로덕션 서버를 가르는 건 에러 처리 품질이에요.
function handleToolError(error: unknown, context: string) { console.error(`[${context}]`, error); if (error instanceof z.ZodError) { return { content: [{ type: 'text' as const, text: `Validation error: ${error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ')}`, }], isError: true, }; } if (error instanceof pg.DatabaseError) { return { content: [{ type: 'text' as const, text: `Database error (${error.code}): ${error.message}` + (error.detail ? `\nDetail: ${error.detail}` : '') + (error.hint ? `\nHint: ${error.hint}` : ''), }], isError: true, }; } return { content: [{ type: 'text' as const, text: `Unexpected error in ${context}: ${error instanceof Error ? error.message : 'Unknown error'}`, }], isError: true, }; }
핵심은 이거예요: 에러 메시지는 프롬프트예요. AI가 에러 메시지를 읽고 다음 행동을 결정하거든요. 실행 가능한 정보를 담아야 해요:
- ❌
"Error: 42P01": AI한테 쓸모없어요 - ✅
"Table 'userz' not found. Did you mean 'users'? Available tables: users, posts, comments": AI가 스스로 수정 가능
MCP 서버 테스트하기
MCP Inspector로 수동 테스트
공식 MCP Inspector는 개발 중에 정말 유용해요:
npx @modelcontextprotocol/inspector tsx src/index.ts
브라우저 UI가 열리면서 등록된 도구·리소스·프롬프트를 확인하고, 테스트 입력으로 도구를 호출하고, JSON-RPC 메시지를 검사할 수 있어요.
Claude Desktop 연동
서버를 ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)에 추가하세요:
{ "mcpServers": { "database": { "command": "tsx", "args": ["/absolute/path/to/src/index.ts"], "env": { "DATABASE_URL": "postgresql://user:pass@localhost:5432/mydb" } } } }
Claude Desktop을 재시작하면 Tool 아이콘에 방금 만든 Tool이 나타나요.
자동화 테스트
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; describe('MCP Database Server', () => { let server: McpServer; let client: Client; beforeEach(async () => { server = createServer(); client = new Client({ name: 'test-client', version: '1.0.0' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await server.connect(serverTransport); await client.connect(clientTransport); }); test('list_tables returns table information', async () => { const result = await client.callTool({ name: 'list_tables', arguments: {} }); expect(result.isError).toBeFalsy(); const tables = JSON.parse(result.content[0].text); expect(tables).toBeInstanceOf(Array); }); test('query rejects INSERT statements', async () => { const result = await client.callTool({ name: 'query', arguments: { sql: "INSERT INTO users (name) VALUES ('hack')" }, }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Only SELECT'); }); });
완성 서버
전체를 합쳐서 프로덕션 레디 서버를 완성해볼게요:
// src/index.ts import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import pg from 'pg'; // ── 설정 ─────────────────────────────────────────────────── const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { console.error('DATABASE_URL environment variable is required'); process.exit(1); } const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 5, options: '-c default_transaction_read_only=on -c statement_timeout=30000', }); pool.query('SELECT 1').catch((err) => { console.error('Failed to connect to database:', err.message); process.exit(1); }); // ── 서버 셋업 ───────────────────────────────────────────── const server = new McpServer({ name: 'mcp-database-server', version: '1.0.0', capabilities: { tools: {}, resources: {}, prompts: {} }, }); // ── Tools ────────────────────────────────────────────────── server.tool( 'list_tables', 'List all tables in the public schema with column count and estimated row count', {}, async () => { const result = await pool.query(` SELECT tablename, (SELECT count(*) FROM information_schema.columns c WHERE c.table_schema = 'public' AND c.table_name = t.tablename) as columns, n_live_tup as estimated_rows FROM pg_stat_user_tables t WHERE schemaname = 'public' ORDER BY tablename `); return { content: [{ type: 'text', text: JSON.stringify(result.rows, null, 2) }] }; } ); server.tool( 'describe_table', 'Get detailed column info, types, constraints, and indexes for a table', { table: z.string().describe('Table name in the public schema') }, async ({ table }) => { const columns = await pool.query(` SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position `, [table]); if (columns.rows.length === 0) { return { content: [{ type: 'text', text: `Table '${table}' not found.` }], isError: true }; } const indexes = await pool.query(` SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = $1 `, [table]); const fks = await pool.query(` SELECT kcu.column_name, ccu.table_name AS foreign_table, ccu.column_name AS foreign_column FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = $1 `, [table]); return { content: [{ type: 'text', text: JSON.stringify({ table, columns: columns.rows, indexes: indexes.rows, foreignKeys: fks.rows }, null, 2), }], }; } ); server.tool( 'query', 'Execute a read-only SQL query. Only SELECT, WITH (CTE), and EXPLAIN allowed. ' + 'Use $1, $2, ... for parameterized values. Returns JSON array.', { sql: z.string().max(10000).describe('PostgreSQL SELECT query'), params: z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])) .optional().describe('Parameter values for $1, $2, ... placeholders'), }, async ({ sql, params }) => { const upper = sql.trim().toUpperCase(); const disallowed = ['INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'TRUNCATE', 'CREATE', 'GRANT', 'REVOKE']; for (const kw of disallowed) { if (new RegExp(`\\b${kw}\\b`).test(upper)) { return { content: [{ type: 'text', text: `Blocked: '${kw}' statements are not allowed.` }], isError: true }; } } try { const result = await pool.query(sql, params || []); const truncated = result.rows.length > 100 ? { rows: result.rows.slice(0, 100), note: `Showing 100 of ${result.rows.length} rows` } : { rows: result.rows, total: result.rows.length }; return { content: [{ type: 'text', text: JSON.stringify(truncated, null, 2) }] }; } catch (err) { const pgErr = err as pg.DatabaseError; return { content: [{ type: 'text', text: `Query failed: ${pgErr.message}${pgErr.hint ? `\nHint: ${pgErr.hint}` : ''}` }], isError: true, }; } } ); // ── Resources ────────────────────────────────────────────── server.resource( 'schema-overview', 'db://schema', { description: 'Complete database schema overview', mimeType: 'application/json' }, async () => { const result = await pool.query(` SELECT t.tablename, json_agg(json_build_object('column', c.column_name, 'type', c.data_type, 'nullable', c.is_nullable = 'YES') ORDER BY c.ordinal_position) as columns FROM pg_stat_user_tables t JOIN information_schema.columns c ON c.table_schema = 'public' AND c.table_name = t.tablename WHERE t.schemaname = 'public' GROUP BY t.tablename ORDER BY t.tablename `); return { contents: [{ uri: 'db://schema', mimeType: 'application/json', text: JSON.stringify(result.rows, null, 2) }] }; } ); // ── Prompts ──────────────────────────────────────────────── server.prompt( 'optimize-query', 'Analyze a SQL query and suggest performance optimizations', { query: z.string().describe('The SQL query to optimize') }, async ({ query }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `Analyze this PostgreSQL query for performance:\n\n\`\`\`sql\n${query}\n\`\`\`\n\nSuggest index optimizations, query rewrites, and explain the reasoning.`, }, }], }) ); // ── 라이프사이클 ────────────────────────────────────────── process.on('SIGINT', async () => { await pool.end(); process.exit(0); }); // ── 시작 ────────────────────────────────────────────────── const transport = new StdioServerTransport(); await server.connect(transport); console.error('MCP Database Server running');
배포 패턴
패턴 1: npm 패키지 (로컬 배포)
npm run build npm pack # .tgz 배포 또는 npm에 퍼블리시
Claude Desktop 설정:
{ "mcpServers": { "database": { "command": "mcp-database-server", "env": { "DATABASE_URL": "postgresql://..." } } } }
패턴 2: Docker (팀/원격)
FROM node:20-slim WORKDIR /app COPY package*.json ./ RUN npm ci --production COPY dist/ ./dist/ EXPOSE 3001 CMD ["node", "dist/sse-server.js"]
패턴 3: 서버리스 (Streamable HTTP)
Vercel, AWS Lambda, Cloudflare Workers용:
// api/mcp.ts (Vercel 서버리스 함수) import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; export default async function handler(req: Request): Promise<Response> { const server = createServer(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); await server.connect(transport); return transport.handleRequest(req); }
실수하기 쉬운 함정들
1. stdout에 로그 찍기
// ❌ JSON-RPC 프로토콜 깨짐 console.log('Server started'); // ✅ 로그는 stderr로 console.error('Server started');
2. 연결 라이프사이클 미처리
// ❌ 연결 끊기면 리소스 누수 const server = new McpServer(config); // ✅ 제대로 정리 server.onclose = async () => { await pool.end(); console.error('Connection closed, resources cleaned up'); };
3. 데이터 과다 반환
// ❌ 테이블 전체를 컨텍스트 윈도우에 쏟아부음 return { content: [{ type: 'text', text: JSON.stringify(allRows) }] }; // ✅ 페이지네이션과 요약 const rows = result.rows.slice(0, 50); return { content: [{ type: 'text', text: JSON.stringify({ rows, total: result.rowCount, note: result.rowCount > 50 ? `Showing 50 of ${result.rowCount} rows. Use LIMIT/OFFSET for pagination.` : undefined, }, null, 2), }], };
4. 에러 컨텍스트 누락
// ❌ AI가 자가 수정 불가 return { content: [{ type: 'text', text: 'Error' }], isError: true }; // ✅ AI가 접근 방식 조정 가능 return { content: [{ type: 'text', text: `Column "user_name" not found in table "users". Available columns: id, email, full_name, created_at. Did you mean "full_name"?`, }], isError: true, };
5. DB 쿼리 타임아웃 미설정
// ❌ 복잡한 쿼리가 커넥션을 영원히 잠금 await pool.query(complexSql); // ✅ 풀 레벨에서 statement_timeout 설정 const pool = new pg.Pool({ connectionString: DATABASE_URL, options: '-c statement_timeout=30000', // 30초 });
MCP의 앞으로
프로토콜이 빠르게 진화하고 있어요. 이미 나온 것과 앞으로 올 걸 나눠서 정리할게요.
이미 스펙에 반영된 것 (2025-11-25):
- OAuth 2.1 인증 : MCP 서버가 OAuth Resource Server로 분류돼요. Protected Resource Metadata 발견이 표준화됐고, 비표준 인증 패턴은 이제 안 써도 돼요.
- Structured Tool Output : Tool이 텍스트뿐 아니라 구조화된 데이터도 반환할 수 있게 됐어요. 클라이언트가 결과를 프로그래매틱하게 처리하기 쉬워졌죠.
- Elicitation : 서버가 상호작용 중에 사용자에게 추가 입력을 요청할 수 있어요. "API 키를 입력하세요", "OAuth 인증을 완료하세요" 같은 플로우요.
- 아이콘 메타데이터 : Tools, Resources, Prompts에 아이콘 URL을 넣을 수 있고,
list응답에서 반환돼서 클라이언트 UI가 풍성해져요. title필드 : 사람이 읽기 좋은 표시명을 위한title필드가 추가됐고,name은 프로그래밍용 식별자로 남아요.
곧 나올 것 (SDK v2 이후):
- 에이전트 간 MCP : MCP 서버가 다른 MCP 서버의 클라이언트가 돼서 조합 가능한 AI Tool 체인을 구성해요.
- Tasks (실험적) : 내구성 있는 상태 추적과 결과의 지연 조회를 위한 새 프리미티브예요. 오래 걸리는 작업에 유용하죠.
- 샌드박스 실행 : 매니페스트 기반 권한 시스템을 갖춘 컨테이너 격리 표준화가 진행 중이에요.
커뮤니티 MCP 서버 레지스트리에는 이미 GitHub, Slack, Jira부터 Kubernetes, AWS, Terraform까지 수백 개의 서버가 있어요. 직접 만들어보는 게 프로토콜을 이해하는 가장 좋은 방법이고, 기성 서버로는 못 하는 초능력을 AI 어시스턴트에 부여하는 길이기도 하고요.
마무리
MCP 서버 만들기, 어렵지 않아요. SDK가 프로토콜 복잡성을 처리해주니까 정말 중요한 것에만 집중하면 돼요. 유스케이스에 맞는 Tools, Resources, Prompts를 정의하는 거죠.
핵심 요약:
- Tool부터 시작하세요. 가장 임팩트 큰 프리미티브예요. Claude Desktop에서 Tool 하나 돌려보고 거기서 확장해 나가면 돼요.
- 설명은 프롬프트 엔지니어링이에요. 진짜로요. AI가 설명을 읽고 Tool 사용 여부와 방법을 결정하거든요.
- 보안은 선택이 아니에요. 입력 검증, 읽기 전용 연결, 레이트 리미팅, 타임아웃은 필수예요.
- 에러 메시지도 프롬프트예요. AI가 스스로 수정할 수 있도록 행동 가능한 에러 컨텍스트를 돌려주세요.
- 트랜스포트는 신중하게. 로컬은 stdio, 원격은 Streamable HTTP. SSE는 이제 안 써요.
"코드에 대해 대화하는 AI"와 "인프라를 실제로 조작하는 AI" 사이의 간극은 MCP 서버 딱 하나 만큼이에요. 그 간극을 메워볼 시간이에요.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요