콘텐츠로 이동

MCP 실전 구현

MCP 서버를 만들기 위해 공식 TypeScript SDK를 설치합니다.

Terminal window
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

tsconfig.json 기본 설정:

{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true
}
}

파일 시스템 도구를 제공하는 간단한 MCP 서버입니다.

src/server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { readFile, writeFile } from 'fs/promises';
const server = new Server(
{ name: 'file-system-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
// 도구 목록 핸들러
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'read_file',
description: '파일 내용을 읽습니다',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: '읽을 파일 경로' },
},
required: ['path'],
},
},
{
name: 'write_file',
description: '파일에 내용을 씁니다',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: '쓸 파일 경로' },
content: { type: 'string', description: '파일 내용' },
},
required: ['path', 'content'],
},
},
],
}));
// 도구 실행 핸들러
const ReadFileParams = z.object({ path: z.string() });
const WriteFileParams = z.object({ path: z.string(), content: z.string() });
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'read_file') {
const { path } = ReadFileParams.parse(args);
const content = await readFile(path, 'utf-8');
return { content: [{ type: 'text', text: content }] };
}
if (name === 'write_file') {
const { path, content } = WriteFileParams.parse(args);
await writeFile(path, content, 'utf-8');
return { content: [{ type: 'text', text: `파일 저장 완료: ${path}` }] };
}
throw new Error(`Unknown tool: ${name}`);
});
// Stdio 전송으로 서버 시작
const transport = new StdioServerTransport();
await server.connect(transport);

Resources는 URI로 식별되는 읽기 전용 데이터를 노출합니다.

import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
const server = new Server(
{ name: 'data-server', version: '1.0.0' },
{ capabilities: { tools: {}, resources: {} } }
);
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: 'config://app/settings',
name: '애플리케이션 설정',
mimeType: 'application/json',
},
],
}));
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
if (uri === 'config://app/settings') {
const settings = await loadAppSettings();
return {
contents: [
{
uri,
mimeType: 'application/json',
text: JSON.stringify(settings, null, 2),
},
],
};
}
throw new Error(`Resource not found: ${uri}`);
});

원격 배포가 필요한 경우 HTTP + SSE 전송을 사용합니다.

import express from 'express';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
const app = express();
app.use(express.json());
const transports = new Map<string, SSEServerTransport>();
// SSE 연결 엔드포인트
app.get('/sse', async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
transports.set(transport.sessionId, transport);
res.on('close', () => {
transports.delete(transport.sessionId);
});
await server.connect(transport);
});
// 클라이언트 메시지 수신
app.post('/messages', async (req, res) => {
const sessionId = req.query.sessionId as string;
const transport = transports.get(sessionId);
if (!transport) {
res.status(404).json({ error: 'Session not found' });
return;
}
await transport.handlePostMessage(req, res);
});
app.listen(3000, () => {
console.log('MCP 서버 실행 중: http://localhost:3000');
});

원격 MCP 서버에서 인증은 RFC 8707 Resource Indicators를 활용한 OAuth 2.0 방식을 사용합니다.

import { createRemoteJWKSet, jwtVerify } from 'jose';
const jwksUri = 'https://auth.example.com/.well-known/jwks.json';
const JWKS = createRemoteJWKSet(new URL(jwksUri));
async function verifyToken(token: string): Promise<TokenPayload> {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://mcp.example.com', // Resource Indicator
});
return payload as TokenPayload;
}
// Express 미들웨어로 적용
function authMiddleware(
req: express.Request,
res: express.Response,
next: express.NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Authorization required' });
return;
}
const token = authHeader.slice(7);
verifyToken(token)
.then((payload) => {
req.user = payload;
next();
})
.catch(() => {
res.status(403).json({ error: 'Invalid token' });
});
}
app.use('/sse', authMiddleware);
app.use('/messages', authMiddleware);

서버를 만들었다면 클라이언트에서 연결하는 코드입니다.

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const transport = new StdioClientTransport({
command: 'node',
args: ['dist/server.js'],
});
const client = new Client(
{ name: 'my-agent', version: '1.0.0' },
{ capabilities: {} }
);
await client.connect(transport);
// 사용 가능한 도구 목록 조회
const { tools } = await client.listTools();
console.log('사용 가능한 도구:', tools.map((t) => t.name));
// 도구 호출
const result = await client.callTool({
name: 'read_file',
arguments: { path: './package.json' },
});
console.log(result.content);
await client.close();

MCP 서버 구현은 SDK의 Server 클래스에 ListToolsCallTool 핸들러를 등록하는 것이 핵심입니다. 로컬 환경은 Stdio, 원격 배포는 HTTP+SSE 전송을 사용하며, OAuth 2.0 Resource Indicators로 인증을 구현합니다. Zod로 파라미터를 검증하면 LLM의 잘못된 인수 전달을 안전하게 처리할 수 있습니다.