MCP 서버를 처음 만들어봤다 — Python·TypeScript로 메모 도구 붙이기
메모 폴더를 읽는 MCP 서버를 Python과 TypeScript로 각각 만들어 VS Code + GitHub Copilot 에이전트 모드에서 호출해봤다. 막혔던 에러와 고객사 구축 때 달라지는 점까지 정리했다.
MCP 이야기를 몇 달째 들었다. 발표 자료에도, 고객 미팅에도 빠지지 않고 나왔다. 그런데 정작 내가 직접 서버를 만들어 본 적은 없었다. 남이 만든 서버를 붙여 쓰는 것과 내가 만든 함수를 AI가 골라 쓰게 만드는 것은 다른 경험일 것 같았다.
그래서 제일 단순한 것부터 해봤다. 내 PC의 notes 폴더에 있는 메모를 읽는 서버다. 도구는 세 개, 메모 목록 보기와 메모 읽기, 키워드 찾기뿐이다. 이걸 Python으로 한 번, TypeScript로 한 번 만들어서 VS Code의 GitHub Copilot 에이전트 모드에 붙였다.
결론부터 말하면 코드는 금방 썼고, 시간은 대부분 에러 로그를 읽는 데 들었다. 그 과정을 그대로 적는다.
MCP를 한 문장으로
AI가 내 함수를 골라서 호출하게 해주는 연결 규격이다.
나는 이렇게 이해했다. Copilot은 머리다. MCP 서버는 손이다. notes 폴더는 손이 만지는 물건이다. 그리고 mcp.json은 손이 어디 있는지 적어둔 쪽지다. 머리는 쪽지를 보고 손을 찾고, 손에 붙은 설명서를 읽고, 어떤 손을 쓸지 스스로 고른다.
처음에 헷갈렸던 게 파일 두 개의 역할이었다. 정리하면 이렇다.
| 파일 | 역할 |
|---|---|
| mcp.json | 서버가 아니라 사용하는 쪽(VS Code) 설정. 도구함이 어디 있고 어떻게 여는지 |
| memo_server.py | 도구함과 도구가 한 파일에 같이 들어 있음 |
준비물
Python 3.10 이상, Node 20 이상, VS Code, 그리고 GitHub Copilot이다. Copilot은 채팅 모드를 Agent로 바꿔야 MCP 도구를 쓴다.
Python 버전
폴더는 이렇게 잡았다. 메모 세 개를 미리 넣어뒀다. 회의 메모, 장보기 목록, 읽을거리다.
1
2
3
4
5
6
7
8
C:\dev\memo-mcp
├─ .vscode\mcp.json
├─ notes\
│ ├─ meeting.md
│ ├─ reading.md
│ └─ shopping.md
├─ memo_server.py
└─ ts\ ← 나중에 만든 TypeScript 버전
가상환경을 만들고 MCP SDK를 설치했다. 버전을 <2로 묶은 이유는 아래 에러 이야기에서 설명한다.
1
2
3
cd C:\dev\memo-mcp
uv venv .venv
uv pip install "mcp[cli]<2"
서버 코드는 이게 전부다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from pathlib import Path
from mcp.server.fastmcp import FastMCP
# 1) 준비: 서버 이름과 메모 폴더 위치
mcp = FastMCP("memo")
NOTES_DIR = Path(__file__).parent / "notes"
# 2) 보조 함수: 폴더 밖 파일을 열지 못하게 막는다
def _note_path(name: str) -> Path:
path = (NOTES_DIR / name).resolve()
if path.parent != NOTES_DIR.resolve() or path.suffix != ".md":
raise ValueError(f"notes 폴더 안의 .md 파일만 열 수 있습니다: {name}")
return path
# 3) 도구 3개: docstring 이 곧 LLM 이 읽는 도구 설명서다
@mcp.tool()
def list_notes() -> list[str]:
"""메모 목록을 보여준다. 어떤 메모가 있는지 모를 때 가장 먼저 쓴다."""
return sorted(p.name for p in NOTES_DIR.glob("*.md"))
@mcp.tool()
def read_note(name: str) -> str:
"""메모 하나의 전체 내용을 읽는다. name 은 list_notes 가 돌려준 파일 이름(예: meeting.md)."""
return _note_path(name).read_text(encoding="utf-8")
@mcp.tool()
def search_notes(keyword: str) -> list[str]:
"""모든 메모에서 keyword 가 들어간 줄을 찾아 '파일명: 줄' 형태로 돌려준다."""
hits = []
for p in sorted(NOTES_DIR.glob("*.md")):
for line in p.read_text(encoding="utf-8").splitlines():
if keyword in line:
hits.append(f"{p.name}: {line.strip()}")
return hits
# 4) 실행: VS Code 가 이 파일을 자식 프로세스로 띄우고 stdin/stdout 으로 대화한다
if __name__ == "__main__":
mcp.run() # 기본값 transport="stdio"
네 덩어리로 읽으면 쉽다.
첫째는 준비다. FastMCP("memo")가 도구함을 만든다. 이름은 Copilot 화면에 그대로 보인다.
둘째는 보조 함수다. read_note에 ../memo_server.py 같은 이름이 들어오면 폴더 밖 파일이 열린다. AI가 일부러 그럴 리는 없겠지만, 도구는 결국 입력을 그대로 믿으면 안 된다. 실제로 이 이름을 넣어 호출해보니 에러가 돌아오고 파일은 열리지 않았다.
셋째가 핵심인 @mcp.tool()이다. 함수에 이 데코레이터를 붙이면 도구가 된다. 함수 이름이 도구 이름, 타입 힌트가 입력 형식, 그리고 docstring이 도구 설명이 된다. 이 설명이 얼마나 중요한지는 뒤에서 다시 쓴다.
넷째는 실행이다. mcp.run()은 기본으로 stdio, 그러니까 표준 입출력으로 통신한다. 서버가 포트를 여는 게 아니라 VS Code가 이 파일을 실행하고 파이프로 말을 주고받는다.
mcp.json으로 VS Code에 알려주기
.vscode\mcp.json에 서버를 등록한다. 이게 아까 말한 쪽지다.
1
2
3
4
5
6
7
8
9
{
"servers": {
"memo": {
"type": "stdio",
"command": "${workspaceFolder}\\.venv\\Scripts\\python.exe",
"args": ["${workspaceFolder}\\memo_server.py"]
}
}
}
파일을 열면 "memo" 위에 Start 버튼이 생긴다. 누르고 잠시 뒤 Running · 3 tools가 뜨면 연결된 것이다.
Copilot에게 시켜보기
Copilot Chat을 Agent 모드로 바꾸고 이렇게 물었다.
메모 목록 보여주고, 회의 메모에서 다음 할 일 찾아줘
Copilot은 먼저 list_notes를 불러 메모 목록을 확인했다. 그다음 회의 메모를 골라 read_note를 불렀고, 메모에 적힌 다음 할 일을 정리해 답했다. 나는 어떤 도구를 어떤 순서로 쓰라고 말한 적이 없다.
내가 겪은 에러: FastMCP를 찾을 수 없다
처음에는 버전을 고정하지 않고 uv pip install "mcp[cli]"로 설치했다. 그러자 최신 2.x가 깔렸다. Start를 누르자 서버가 바로 죽었다.
VS Code에서 원인을 찾는 방법은 이랬다. 서버 이름 옆에 뜬 Error를 클릭하면 Output 패널이 열리고, 서버가 남긴 로그가 그대로 보인다. 거기에 이런 줄이 있었다.
1
2
3
4
ModuleNotFoundError: No module named 'mcp.server.fastmcp'.
This is mcp 2.x, where FastMCP was renamed to MCPServer
(from mcp.server.mcpserver import MCPServer) and other APIs changed;
see the migration guide ... or pin 'mcp<2' to keep running v1 code.
2.x에서 FastMCP가 MCPServer로 이름이 바뀌었다는 뜻이다. 인터넷에 있는 예제와 튜토리얼은 대부분 1.x 기준이라 from mcp.server.fastmcp import FastMCP로 시작한다. 예제를 그대로 따라 하면 여기서 막힌다.
해결은 두 가지다. 2.x 마이그레이션 가이드를 따라 코드를 고치거나, 버전을 1.x로 묶는 것이다. 처음 배우는 단계라 예제와 맞추는 게 우선이라고 보고 mcp<2로 고정했다. 다시 설치하니 1.x가 깔렸고 서버가 떴다.
에러 메시지가 해결책까지 알려주는데도, 로그를 열어보기 전까지는 그냥 “서버가 안 뜬다”로만 보였다. Output 패널을 여는 습관이 이번 실습에서 얻은 제일 실용적인 교훈이었다.
TypeScript 버전
같은 서버를 TypeScript로도 만들었다. ts 폴더에서 패키지를 설치했다.
1
2
3
cd C:\dev\memo-mcp\ts
npm init -y
npm install @modelcontextprotocol/sdk zod tsx
index.ts는 Python과 모양이 거의 같다. 다른 점은 데코레이터 대신 registerTool로 등록하고, 입력 형식을 타입 힌트 대신 zod로 적는다는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// 1) 준비: 서버 이름과 메모 폴더 위치 (Python 버전과 같은 notes 폴더를 쓴다)
const server = new McpServer({ name: "memo-ts", version: "1.0.0" });
const NOTES_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "notes");
// 2) 보조 함수: 폴더 밖 파일을 열지 못하게 막는다
function notePath(name: string): string {
const p = path.resolve(NOTES_DIR, name);
if (path.dirname(p) !== NOTES_DIR || path.extname(p) !== ".md") {
throw new Error(`notes 폴더 안의 .md 파일만 열 수 있습니다: ${name}`);
}
return p;
}
async function mdFiles(): Promise<string[]> {
return (await readdir(NOTES_DIR)).filter((f) => f.endsWith(".md")).sort();
}
const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });
// 3) 도구 3개: description 이 Python 의 docstring 역할
server.registerTool(
"list_notes",
{ description: "메모 목록을 보여준다. 어떤 메모가 있는지 모를 때 가장 먼저 쓴다." },
async () => text((await mdFiles()).join("\n")),
);
server.registerTool(
"read_note",
{
description: "메모 하나의 전체 내용을 읽는다. name 은 list_notes 가 돌려준 파일 이름(예: meeting.md).",
inputSchema: { name: z.string() },
},
async ({ name }) => text(await readFile(notePath(name), "utf-8")),
);
server.registerTool(
"search_notes",
{
description: "모든 메모에서 keyword 가 들어간 줄을 찾아 '파일명: 줄' 형태로 돌려준다.",
inputSchema: { keyword: z.string() },
},
async ({ keyword }) => {
const hits: string[] = [];
for (const f of await mdFiles()) {
for (const line of (await readFile(path.join(NOTES_DIR, f), "utf-8")).split("\n")) {
if (line.includes(keyword)) hits.push(`${f}: ${line.trim()}`);
}
}
return text(hits.join("\n"));
},
);
// 4) 실행: stdio 로 연결 (stdout 은 프로토콜 전용이라 로그는 console.error 로)
await server.connect(new StdioServerTransport());
console.error("memo-ts 서버 시작");
나란히 놓으면 대응 관계가 바로 보인다.
| Python | TypeScript |
|---|---|
FastMCP("memo") | new McpServer({ name: "memo-ts", ... }) |
@mcp.tool() + 함수 | server.registerTool("이름", { ... }, 함수) |
| docstring | description |
타입 힌트 name: str | inputSchema: { name: z.string() } |
mcp.run() | server.connect(new StdioServerTransport()) |
mcp.json에는 서버를 하나 더 적었다.
1
2
3
4
5
"memo-ts": {
"type": "stdio",
"command": "npx",
"args": ["tsx", "${workspaceFolder}\\ts\\index.ts"]
}
TypeScript에서도 두 번 막혔다. 둘 다 실제로 실행해서 확인한 에러다.
첫 번째는 package.json에 "type": "module"이 없을 때였다. 마지막 줄의 await(최상위 await) 때문에 tsx가 이렇게 멈췄다.
1
ERROR: Top-level await is currently not supported with the "cjs" output format
npm pkg set type=module로 한 줄 추가하니 해결됐다.
두 번째는 디버깅하려고 console.log를 넣었을 때였다. stdio 방식에서 표준 출력은 MCP 메시지가 오가는 통로다. 거기에 로그를 섞으니 클라이언트 쪽에 Failed to parse JSONRPC message from server 경고가 찍혔다. 로그는 반드시 console.error로 표준 에러에 써야 한다.
이 둘을 고치자 memo-ts도 도구 세 개가 잡혔다. 같은 notes 폴더를 보기 때문에 호출 결과도 Python 버전과 똑같았다.
도구는 어떤 순서로 불리나
내가 처음에 오해했던 것
MCP 서버를 일종의 지침서라고 생각했다. “이런 질문이 오면 이 함수를 써라”를 서버가 정해두는 줄 알았다. 실제로는 반대였다. 서버는 설명서가 붙은 도구함일 뿐이다. 언제 어떤 도구를 꺼낼지는 LLM이 설명서를 읽고 판단한다.
LLM이 서버와 직접 통신한다는 것도 오해였다. 서버를 실행하고 도구를 호출하는 건 VS Code 같은 호스트다. LLM은 “이 도구를 이 인자로 불러줘”라고 말할 뿐이고, 실제 호출은 호스트가 한다. Claude Desktop이나 Copilot Studio도 같은 자리에 있다. 그래서 같은 서버를 어떤 LLM에도 붙일 수 있다. 내가 만든 memo_server.py는 Copilot 전용이 아니다.
그러다 보니 docstring이 코드만큼 중요하다는 것도 알게 됐다. LLM이 보는 건 함수 본문이 아니라 이름과 설명뿐이다. list_notes의 설명에 “어떤 메모가 있는지 모를 때 가장 먼저 쓴다”는 문장을 넣은 이유가 그거다. 설명이 모호하면 도구가 있어도 안 쓰거나 엉뚱한 때 쓴다. 도구 설명은 사람이 읽는 주석이 아니라 AI에게 주는 사용 설명서다.
로컬 실습과 고객사 구축은 무엇이 다른가
이번 실습은 전부 내 노트북 안에서 끝났다. 고객사에 같은 걸 만든다면 거의 모든 칸이 바뀐다.
| 로컬 실습 | 고객사 구축 | |
|---|---|---|
| 통신 | stdio (내 PC 안) | Streamable HTTP (원격 URL) |
| 위치 | 내 노트북 | Azure Container Apps 등 |
| 인증 | 없음 | API Key 또는 OAuth 2.0 (Entra ID) |
| 데이터 | notes 폴더 | 사내 DB, SharePoint, ERP API |
Copilot Studio에 붙인다면 통신 방식은 선택지가 없다. MS Learn 문서에 따르면 Copilot Studio는 현재 Streamable 방식만 지원하고, SSE는 2025년 8월 이후 지원하지 않는다. 인증은 없음, API Key, OAuth 2.0 중에서 고른다. 그리고 Copilot Studio의 MCP 연결은 Power Platform 커넥터를 통하기 때문에, 커넥터에 걸린 데이터 정책(DLP)이 MCP 서버에도 그대로 적용된다. 고객사에서는 이 부분을 관리자와 먼저 맞춰야 할 것 같다. (MS Learn: Connect your agent to an existing MCP server)
서버를 어디에 올릴지도 정해야 한다. 후보로 본 게 Azure Container Apps다. Kubernetes 기반의 서버리스 컨테이너 플랫폼이고, 요청이 없을 때 0개까지 줄어드는 scale to zero를 지원한다. 호출이 드문 사내 도구 서버에는 잘 맞아 보인다. (MS Learn: Comparing Container Apps with other Azure container options)
VM과 비교하면 차이가 확실하다.
| 비유 | 직접 관리하는 것 | |
|---|---|---|
| VM | 빈 사무실 통째로 임대 | OS, 패치, 보안, 런타임, 상시 가동 |
| Container Apps | 짐만 들고 가는 공유오피스 | 컨테이너(코드 상자)만 |
다음에 해볼 것
이번 서버는 읽기만 한다. 다음에는 write_note 도구를 추가해서 Copilot이 메모를 직접 쓰게 해볼 생각이다. 읽기와 달리 쓰기는 사고가 날 수 있는 도구라서, 호스트가 실행 전에 확인을 받는지 보는 게 관심사다.
그다음은 같은 서버를 stdio가 아니라 HTTP 방식으로 바꿔 띄워보는 것이다. 표에 적은 고객사 구성으로 가려면 결국 이 단계를 거쳐야 한다.
MCP가 기존 API 호출과 무엇이 다른지는 앞서 MCP와 API는 무엇이 다른가에 정리해뒀다.