콘텐츠로 이동

MCP 실전 구현

MCP 클라이언트
│ Stdio 또는 Streamable HTTP
▼
SDK: 버전·capability·resultType 처리
▼
서버: 인가·입력 스키마·실제 경로 검사
├── Tools: 실제 작업 실행
└── Resources: URI로 읽기 전용 자료 제공

Node.js 20 이상을 사용하는 별도 실습 디렉터리에서 실행합니다. SDK v2는 ES module 패키지입니다.

Terminal window
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server@2 @modelcontextprotocol/client@2 zod@4
npm install -D typescript @types/node tsx
mkdir -p src workspace

tsx로 TypeScript를 직접 실행합니다. 설치 결과의 lockfile도 함께 보관해 같은 SDK 버전을 재현합니다.

src/server.ts에 서버 생성 함수를 정의합니다. registerTool은 Zod 스키마로 JSON Schema를 만들고 실행 전 인수를 검사합니다. 별도의 tools/list·tools/call 핸들러를 수동으로 작성할 필요가 없습니다.

다음 예제는 workspace 아래의 기존 파일을 읽거나 덮어씁니다. 새 파일 생성과 디렉터리 생성은 범위에 포함하지 않습니다. 실제 경로를 검사하므로 ../workspace-other와 외부 심볼릭 링크를 거부합니다.

src/server.ts
import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
import { readFile, writeFile, realpath } from 'node:fs/promises';
import path from 'node:path';
const workspace = await realpath('./workspace');
async function resolveFile(requested: string): Promise<string> {
const resolved = await realpath(path.resolve(workspace, requested));
const relative = path.relative(workspace, resolved);
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
throw new Error('Path is outside the workspace');
}
return resolved;
}
export function createServer(): McpServer {
const server = new McpServer({ name: 'file-system-server', version: '1.0.0' });
server.registerTool('read_file', {
description: 'Read an existing file inside the workspace',
inputSchema: z.object({ path: z.string() }),
}, async ({ path: requested }) => ({
content: [{ type: 'text', text: await readFile(await resolveFile(requested), 'utf8') }],
}));
server.registerTool('write_file', {
description: 'Replace an existing workspace file after host authorization',
inputSchema: z.object({ path: z.string(), content: z.string() }),
}, async ({ path: requested, content }) => {
await writeFile(await resolveFile(requested), content, 'utf8');
return { content: [{ type: 'text', text: `Saved: ${requested}` }] };
});
server.registerResource('settings', 'config://app/settings', {
mimeType: 'application/json',
description: 'Public application settings without credentials',
}, async uri => ({
contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"theme":"light"}' }],
}));
return server;
}

진입점을 src/stdio.ts로 분리합니다. 같은 서버 팩터리를 HTTP에서도 재사용할 수 있습니다.

src/stdio.ts
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './server.js';
void serveStdio(createServer);
console.error('MCP server is ready on stdio');

stdout은 JSON-RPC 전용 채널입니다. 로그는 console.error로 stderr에 기록합니다. MCP Inspector로 도구 인수와 결과를 직접 살펴볼 수 있습니다.

Terminal window
npx @modelcontextprotocol/inspector npx tsx src/stdio.ts

Inspector가 서버 프로세스를 실행하므로 같은 서버를 별도로 시작하지 않습니다. workspace/example.txt를 만든 뒤 read_file을 호출하고, 외부 경로가 거부되는지도 확인합니다.

위 서버의 settings는 고정 URI 리소스입니다. 클라이언트가 resources/list로 찾고 resources/read로 읽습니다. 여러 문서가 있다면 ResourceTemplate으로 URI 패턴을 등록합니다.

import { ResourceTemplate } from '@modelcontextprotocol/server';
// createServer() 내부에서 return 전에 등록
server.registerResource('document',
new ResourceTemplate('docs://{file}', { list: undefined }),
{ mimeType: 'text/plain' },
async (uri, { file }) => ({
contents: [{ uri: uri.href, text: await readFile(await resolveFile(String(file)), 'utf8') }],
}),
);

list: undefined는 개별 URI를 열거하지 않는다는 뜻입니다. 템플릿은 resources/templates/list에 노출되며, 클라이언트가 실제 file 값으로 URI를 구성합니다. 리소스 내용에 API 키나 토큰을 포함하지 않습니다.

원격 연결에는 SDK v2의 요청별 서버 팩터리를 사용합니다. Mcp-Session-Id로 transport를 저장하는 과거 예제가 필요하지 않습니다.

Terminal window
npm install @modelcontextprotocol/express@2 @modelcontextprotocol/node@2 express
src/http.ts
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { createServer } from './server.js';
const app = createMcpExpressApp();
const handler = toNodeHandler(createMcpHandler(createServer));
app.all('/mcp', (req, res) => void handler(req, res, req.body));
app.listen(3000, '127.0.0.1');

기본 로컬 Host·Origin 검사는 DNS rebinding을 방어합니다. 공개 배포에서는 허용 호스트를 명시하고 HTTPS·인가를 추가합니다. 서버 팩터리는 요청마다 실행되므로 여러 도구 호출에 필요한 작업 상태는 DB 등에 저장하고 작업 핸들을 전달합니다.

보호된 HTTP MCP 서버는 OAuth 2.1 리소스 서버로 동작합니다. 클라이언트는 Protected Resource Metadata로 인가 서버를 찾고, 해당 MCP 리소스를 resource 값으로 지정해 토큰을 받습니다.

인가되지 않은 요청
→ 401 + WWW-Authenticate(resource_metadata, scope)
→ 클라이언트가 Protected Resource Metadata 조회
→ 인가 서버 발견, 클라이언트 등록, PKCE 인가
→ resource가 지정된 액세스 토큰 획득
→ MCP 서버가 토큰 대상·발급자·만료·scope 검사
→ 허용된 도구 실행

SDK의 requireBearerAuth({ verifier })를 /mcp 라우트의 핸들러보다 앞에 배치합니다. verifier는 운영 환경의 인가 서버에 맞게 구현해야 합니다. JWT라면 서명·issuer·audience·만료를 검사하고, 불투명 토큰은 인가 서버의 검증 방식에 따릅니다.

import { requireBearerAuth } from '@modelcontextprotocol/express';
// HTTP 예제의 app.all을 다음 형태로 교체합니다.
// verifier는 인가 서버에 맞게 구현한 AccessToken 검증 객체입니다.
const auth = requireBearerAuth({ verifier });
app.all('/mcp', auth, (req, res) => void handler(req, res, req.body));

이 단편만으로 전체 OAuth 서버가 구현되지는 않습니다. 메타데이터·클라이언트 등록·PKCE·scope·사용자별 인가를 별도로 구성합니다. 다른 서비스용 토큰을 그대로 통과시키지 않습니다. Stdio는 이 HTTP OAuth 흐름 대신 환경의 자격 증명을 사용하며, 로컬 프로세스의 권한 제한은 여전히 필요합니다.

src/client.ts
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const client = new Client({ name: 'my-agent', version: '1.0.0' });
const transport = new StdioClientTransport({
command: 'npx',
args: ['tsx', 'src/stdio.ts'],
});
try {
await client.connect(transport);
const { tools } = await client.listTools();
console.log(tools.map(tool => tool.name));
const result = await client.callTool({
name: 'read_file',
arguments: { path: 'example.txt' },
});
if (result.isError) throw new Error('Tool execution failed');
console.log(result.content);
const settings = await client.readResource({ uri: 'config://app/settings' });
console.log(settings.contents);
} finally {
await client.close();
}

connect() 이후에도 모든 서버가 동일한 명세를 사용하는 것은 아닙니다. SDK가 지원하는 프로토콜과 상대 서버의 지원 버전을 함께 확인합니다. 이전 명세와의 연결은 SDK의 호환 모드가 처리합니다.

SDK v2에서는 McpServer의 도구·리소스 등록과 서버 팩터리를 중심으로 구현합니다. 로컬은 Stdio, 공유 HTTP 서비스는 요청별 Streamable HTTP 핸들러를 사용합니다. 입력 타입 검사, 실제 파일 경로 검사, OAuth 인가와 OS 격리를 각각 적용하고, 클라이언트는 성공 응답의 isError도 검사합니다.