2부 8장. TypeScript로 같은 서버를 다시 만들어보기
2부 8장. TypeScript로 같은 서버를 다시 만들어보기
지난 몇 장 동안 우리는 Python으로 PKM-MCP를 키워 왔다. search_notes를 붙였고, 에러를 다듬었고, Resource와 Prompt를 얹었고, semantic_search_notes까지 넣어 읽기 전용 메모 서버를 제법 그럴듯하게 만들었다. 이제 슬슬 이런 생각이 든다. "굳이 같은 서버를 또 만들어야 하나요?"
결론부터 말하면, 그렇다. 같은 서버를 다른 언어로 한 번 더 만들어 보면 프로토콜의 본체가 훨씬 또렷하게 보인다. Python에서는 너무 자연스럽게 지나갔던 부분이 TypeScript에서는 갑자기 선명해진다. 반대로 TypeScript에서 번거롭게 느껴지던 부분은 왜 Python이 사랑받는지도 보여준다.
이번 장의 목표는 단순히 문법을 옮기는 것이 아니다. 같은 PKM-MCP를 Python과 TypeScript 두 시선으로 바라보면서, 어떤 팀이 어떤 선택을 해야 하는지 기준을 세우는 것이다. 특히 이 책의 후반부에는 OAuth, Cloudflare Workers, MCP Apps UI, 레지스트리 등록까지 기다리고 있으니, 언어 선택은 취향 문제가 아니라 설계 문제이기도 하다.

같은 서버를 두 번 만드는 이유
같은 집을 나무로 한 번 짓고, 철근으로 한 번 더 지어 보면 구조를 이해하게 된다. MCP 서버도 비슷하다. 기능은 같아도, 어떤 언어는 "함수부터 쓰자"라고 말하고, 어떤 언어는 "계약부터 적자"라고 말한다.
Python의 FastMCP는 대체로 함수 중심이다. @mcp.tool()를 붙이고 타입 힌트와 docstring을 잘 적으면 서버가 금방 살아난다. 반면 TypeScript의 공식 SDK는 스키마, 반환 형식, 전송 방식을 더 명시적으로 드러낸다. 한마디로 Python은 "빨리 움직이게" 만들고, TypeScript는 "어떻게 움직이는지 보이게" 만든다.
이 차이는 학습용 프로젝트에서는 취향처럼 느껴질 수 있다. 하지만 서버가 커지고, 팀원이 늘고, 배포 대상이 로컬 노트북을 넘어가면 이야기가 달라진다. 그때는 코드 몇 줄이 적으냐보다, 실수를 얼마나 빨리 잡는지, 배포 환경과 얼마나 잘 맞는지, 프로토콜의 세부 기능을 어디까지 제어할 수 있는지가 더 중요해진다.
8.1 Python vs TypeScript: 언제 어느 쪽을 쓸까
2026년 기준으로 Python과 TypeScript는 둘 다 MCP의 가장 성숙한 축에 속한다. 공식 생태계에서도 두 언어는 가장 먼저 자리 잡은 Tier 1급 선택지로 취급된다.1 그러니 둘 중 무엇을 고르든 "비주류를 탔다"는 걱정은 하지 않아도 된다.
다만 둘의 성격은 꽤 다르다.
| 관점 | Python + FastMCP | TypeScript + 공식 SDK |
|---|---|---|
| 첫 개발 속도 | 매우 빠름 | 빠르지만 더 명시적 |
| 도구 정의 방식 | 함수 시그니처 + docstring 추론 | Zod 스키마 + 설명을 직접 작성 |
| 타입 안정성 | 런타임 중심 | 컴파일 타임 + 런타임 |
| AI/데이터 생태계 | 매우 강함 | 웹/플랫폼 생태계가 강함 |
| 배포 감각 | 스크립트·백엔드·데이터 작업에 편함 | Node, Bun, Deno, 웹/에지 런타임과 잘 맞음 |
| 프로토콜 학습 효과 | 추상화가 좋아 빨리 익힘 | 구조가 보여서 깊이 이해하기 좋음 |
Python이 빛나는 순간은 분명하다. 이미 데이터 처리 코드가 Python에 있고, 임베딩 파이프라인이나 분석 로직도 Python 라이브러리에 기대고 있다면 굳이 언어를 바꿀 이유가 없다. 게다가 FastMCP는 함수 몇 개만 써도 서버가 나온다. 아이디어 검증 단계에서는 거의 반칙처럼 빠르다.
TypeScript가 빛나는 순간도 분명하다. 웹 백엔드, 프론트엔드, Node 생태계, npm 배포, 팀 단위 타입 안정성이 중요한 환경이라면 TypeScript 쪽이 점점 유리해진다. 특히 이 책의 후반부처럼 Cloudflare Workers, 웹 UI, OAuth, 서버 카드, 레지스트리 등록까지 시야에 들어오면, TypeScript는 점점 "대안"이 아니라 "본선 후보"가 된다.
이번 장에서 우리가 TypeScript를 택하는 이유도 여기에 있다. Python 버전을 버리자는 뜻이 아니다. 오히려 반대다. Python으로 빠르게 이해한 것을 TypeScript로 다시 세우면서, PKM-MCP를 더 오래가는 형태로 바라보려는 것이다.
이번 장에서 옮길 PKM-MCP의 핵심 3도구
이번 재구현에서는 PKM-MCP의 읽기 중심 흐름을 유지하되, 도구 세 개에 집중하겠다.
search_notes: 정확한 키워드, 제목, 태그, 식별자 검색semantic_search_notes: 의미 기반 검색rebuild_note_index: 의미 검색용 인덱스를 다시 만드는 관리 도구
여기서 note://{id} 같은 Resource는 그대로 유지할 수 있다. 다만 이번 장의 초점은 "TypeScript에서 도구를 어떻게 설계하느냐"이므로, Resource와 Prompt는 배경으로 두고 Tool에 집중한다.
재미있는 점은 여기서 벌써 언어 철학 차이가 드러난다는 것이다. Python에서는 인덱싱을 외부 스크립트로 빼놓아도 별로 어색하지 않았다. TypeScript에서는 이런 관리 작업을 아예 MCP Tool로 드러내는 편이 오히려 운영 감각에 잘 맞는다. 왜냐하면 "무엇을 자동화할 수 있는가"가 코드 구조에 더 자연스럽게 녹아들기 때문이다.
TypeScript 프로젝트 준비하기
문서를 보다 보면 TypeScript SDK의 새 패키지 구조가 보일 수 있다. 하지만 저장소 기준으로는 v2 계열이 아직 개발 중이었고, 안정적인 운영에는 v1.x가 계속 권장되고 있었다.2 npm에서는 @modelcontextprotocol/sdk 패키지가 여전히 널리 쓰이는 안정판이었다.3 그래서 이번 장은 실무에서 가장 많이 만나는 안정적인 패턴으로 진행하겠다.
먼저 프로젝트를 만든다.
mkdir pkm-mcp-ts
cd pkm-mcp-ts
npm init -y
npm install @modelcontextprotocol/sdk zod openai
npm install -D typescript tsx @types/nodepackage.json은 이렇게 시작하면 무난하다.
{
"name": "pkm-mcp-ts",
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"start": "node build/index.js"
}
}tsconfig.json도 너무 욕심내지 말고 단단하게 시작하자.
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}프로젝트 구조는 이 정도면 충분하다.
pkm-mcp-ts/
├─ notes/
│ ├─ oauth-refresh-flow.md
│ ├─ workers-deploy.md
│ └─ rag-ideas.md
├─ data/
│ └─ semantic-index.json
├─ src/
│ └─ index.ts
├─ package.json
└─ tsconfig.json먼저 느껴보는 차이: 함수 중심 vs 계약 중심
Python에서의 search_notes는 대체로 이런 느낌이었다.
@mcp.tool()
async def search_notes(query: str, limit: int = 5) -> list[dict]:
"""메모를 키워드로 검색합니다."""
...짧고 예쁘다. 마치 원래 함수였는데, MCP가 살짝 양념만 쳐준 것 같다.
TypeScript에서는 같은 기능이 이런 분위기로 바뀐다.
server.registerTool(
"search_notes",
{
description: "메모를 키워드로 검색합니다.",
inputSchema: {
query: z.string().min(1).describe("찾고 싶은 단어, 제목, 태그, 코드 조각"),
limit: z.number().int().min(1).max(10).default(5)
.describe("반환할 최대 결과 수")
}
},
async ({ query, limit }) => {
...
}
);한눈에 봐도 성격이 다르다. Python은 "함수가 스펙이 된다" 쪽이고, TypeScript는 "스펙을 먼저 적고 함수가 따라온다" 쪽이다.
처음에는 TypeScript 쪽이 더 장황하게 느껴질 수 있다. 그런데 도구가 늘어나면 생각이 바뀐다. 입력 스키마가 코드에 대놓고 보이고, .describe()가 누락되면 금방 어색함이 드러나고, 타입이 틀리면 에디터가 먼저 소리친다. 한마디로 덜 낭만적이지만, 더 조직적이다.
PKM-MCP TypeScript 골격 만들기
이제 진짜 서버를 세워 보자. 먼저 메모를 읽고, 청크를 만들고, 검색 점수를 계산하는 헬퍼부터 만든다.
// src/index.ts
import { promises as fs } from "node:fs";
import path from "node:path";
import OpenAI from "openai";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
type Note = {
id: string;
title: string;
path: string;
body: string;
};
type SemanticChunk = {
chunkId: string;
noteId: string;
title: string;
path: string;
chunkIndex: number;
text: string;
embedding: number[];
};
const NOTES_DIR = path.resolve("notes");
const INDEX_PATH = path.resolve("data/semantic-index.json");
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const server = new McpServer({
name: "pkm-mcp-ts",
version: "0.1.0"
});
async function loadNotes(): Promise<Note[]> {
const files = await fs.readdir(NOTES_DIR);
const notes: Note[] = [];
for (const file of files.filter((name) => name.endsWith(".md"))) {
const fullPath = path.join(NOTES_DIR, file);
const body = await fs.readFile(fullPath, "utf-8");
notes.push({
id: path.basename(file, ".md"),
title: path.basename(file, ".md"),
path: fullPath,
body
});
}
return notes;
}
function chunkText(text: string, chunkSize = 600, overlap = 100): string[] {
const clean = text.trim();
if (!clean) return [];
const chunks: string[] = [];
let start = 0;
while (start < clean.length) {
const end = start + chunkSize;
chunks.push(clean.slice(start, end));
if (end >= clean.length) break;
start = end - overlap;
}
return chunks;
}
function tokenize(query: string): string[] {
return query
.toLowerCase()
.split(/s+/)
.map((token) => token.trim())
.filter(Boolean);
}
function keywordScore(note: Note, query: string): number {
const haystack = `${note.title}n${note.body}`.toLowerCase();
const tokens = tokenize(query);
let score = 0;
for (const token of tokens) {
if (note.title.toLowerCase().includes(token)) score += 3;
if (haystack.includes(token)) score += 1;
}
return score;
}
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
async function embedText(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text
});
return response.data[0].embedding;
}여기서 중요한 건 TypeScript가 특별히 마법을 부린다는 점이 아니다. 오히려 마법이 덜하다. 대신 Note, SemanticChunk, 함수 반환 타입이 다 드러난다. 이게 바로 TypeScript 버전의 장점이다. 서버가 커질수록 "대충 이런 모양일 것"이 아니라 "정확히 이 모양이다"라고 말해 준다.
첫 번째 도구: search_notes
이제 키워드 검색 도구를 붙여 보자. 지난 장의 메시지를 그대로 가져오면 된다. 정확한 문자열, 제목, 태그, 식별자, 코드 조각은 키워드 검색이 더 강하다.
server.registerTool(
"search_notes",
{
description:
"메모를 키워드로 검색합니다. 정확한 제목, 태그, 에러 코드, 함수 이름, 파일명처럼 문자열 일치가 중요한 경우에 적합합니다.",
inputSchema: {
query: z.string().min(1).describe("찾고 싶은 단어, 제목, 태그, 코드 조각"),
limit: z.number().int().min(1).max(10).default(5)
.describe("반환할 최대 결과 수")
}
},
async ({ query, limit }) => {
const notes = await loadNotes();
const items = notes
.map((note) => ({
noteId: note.id,
title: note.title,
path: note.path,
snippet: note.body.slice(0, 220),
score: keywordScore(note, query)
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit);
return {
content: [
{
type: "text",
text:
items.length === 0
? `"${query}"와 일치하는 메모를 찾지 못했습니다.`
: items
.map(
(item, index) =>
`${index + 1}. ${item.title}n- id: ${item.noteId}n- path: ${item.path}n- score: ${item.score}n- snippet: ${item.snippet}`
)
.join("nn")
}
],
structuredContent: {
query,
items
}
};
}
);이 코드에서 눈여겨볼 점은 두 가지다.
첫째, TypeScript에서는 입력 설명이 더 노골적으로 드러난다. query가 무엇인지, limit는 왜 있는지, 이 도구가 어떤 상황에 맞는지 모두 코드 옆에 붙어 있다. 지난 장들에서 우리가 그렇게 강조했던 "도구 설명도 스펙이다"라는 말을, TypeScript는 아주 물리적으로 실감하게 만든다.
둘째, structuredContent를 함께 반환하는 점이다. Python 버전에서는 문자열만 반환해도 충분히 시작할 수 있었다. 하지만 TypeScript에서는 결과를 텍스트와 구조화된 데이터로 나눠 반환하는 습관을 들이기 좋다. 사람이 읽기에는 텍스트가 편하고, 클라이언트나 후처리에는 구조화된 데이터가 편하다. 이 감각은 나중에 UI를 붙일 때 특히 빛난다.
두 번째 도구: semantic_search_notes
의미 검색은 지난 장에서 이미 개념을 배웠다. 이번 장에서는 초점을 SDK 비교에 맞추기 위해, 벡터 DB 대신 로컬 JSON 인덱스를 읽는 단순한 형태로 구현해 보겠다. 핵심은 저장소가 아니라 도구 구조다.
먼저 인덱스를 읽는 헬퍼를 추가한다.
async function loadSemanticIndex(): Promise<SemanticChunk[]> {
try {
const raw = await fs.readFile(INDEX_PATH, "utf-8");
return JSON.parse(raw) as SemanticChunk[];
} catch {
return [];
}
}그리고 의미 검색 도구를 등록한다.
server.registerTool(
"semantic_search_notes",
{
description:
"메모를 의미 기반으로 검색합니다. 정확한 단어가 기억나지 않아도, 주제나 의도를 자연어로 설명하면 관련 메모 조각을 찾아줍니다. 정확한 식별자 검색은 search_notes를 먼저 사용하세요.",
inputSchema: {
query: z.string().min(1).describe("찾고 싶은 개념이나 주제를 자연어로 설명하세요"),
limit: z.number().int().min(1).max(10).default(5)
.describe("반환할 최대 결과 수")
}
},
async ({ query, limit }) => {
const index = await loadSemanticIndex();
if (index.length === 0) {
return {
content: [
{
type: "text",
text: "의미 검색 인덱스가 비어 있습니다. rebuild_note_index를 먼저 실행하세요."
}
],
isError: true
};
}
const queryEmbedding = await embedText(query);
const items = index
.map((chunk) => ({
noteId: chunk.noteId,
title: chunk.title,
path: chunk.path,
chunkIndex: chunk.chunkIndex,
snippet: chunk.text.slice(0, 260),
score: Number(cosineSimilarity(queryEmbedding, chunk.embedding).toFixed(4))
}))
.sort((a, b) => b.score - a.score)
.slice(0, limit);
return {
content: [
{
type: "text",
text: items
.map(
(item, index) =>
`${index + 1}. ${item.title}n- note_id: ${item.noteId}n- chunk_index: ${item.chunkIndex}n- score: ${item.score}n- snippet: ${item.snippet}`
)
.join("nn")
}
],
structuredContent: {
query,
items
}
};
}
);이 부분에서 TypeScript는 꽤 솔직하다. Python처럼 "의미 검색입니다" 하고 우아하게 끝나지 않는다. 인덱스가 없으면 어떻게 할지, 점수를 어떤 형식으로 줄지, 반환 구조를 어떻게 고정할지 하나하나 드러낸다.
이게 귀찮게 느껴질 수도 있다. 그런데 바로 그 점 때문에 실무에서는 편하다. 결과 객체의 모양이 눈앞에 있으니, 나중에 다른 개발자가 읽어도 "아, 이 도구는 이런 데이터를 내놓는구나"를 바로 파악할 수 있다. 특히 UI를 붙이거나 테스트 코드를 쓸 때 이 차이가 커진다.
세 번째 도구: rebuild_note_index
이제 의미 검색이 돌아가려면 인덱스를 다시 만들 수 있어야 한다. Python에서는 별도 스크립트로 빼기 쉬웠던 작업을, 이번에는 MCP Tool로 직접 노출하자.
server.registerTool(
"rebuild_note_index",
{
description:
"메모 파일들을 다시 읽어 의미 검색용 임베딩 인덱스를 재생성합니다. 새 메모를 추가했거나 기존 메모를 크게 수정한 뒤에 사용합니다.",
inputSchema: {}
},
async () => {
const notes = await loadNotes();
const chunks: SemanticChunk[] = [];
for (const note of notes) {
const noteChunks = chunkText(note.body);
for (let i = 0; i < noteChunks.length; i++) {
const text = noteChunks[i];
const embedding = await embedText(text);
chunks.push({
chunkId: `${note.id}-${i}`,
noteId: note.id,
title: note.title,
path: note.path,
chunkIndex: i,
text,
embedding
});
}
}
await fs.mkdir(path.dirname(INDEX_PATH), { recursive: true });
await fs.writeFile(INDEX_PATH, JSON.stringify(chunks, null, 2), "utf-8");
return {
content: [
{
type: "text",
text: `인덱스 재생성 완료: ${notes.length}개 메모, ${chunks.length}개 청크`
}
],
structuredContent: {
noteCount: notes.length,
chunkCount: chunks.length
}
};
}
);이 도구가 들어오면 PKM-MCP는 조금 더 서버 같아진다. 단순히 검색만 하는 것이 아니라, 검색을 위한 준비 작업까지 자기 안에서 다룰 수 있기 때문이다.
물론 운영 환경에서는 배치 임베딩, 변경 감지, 재색인 전략, 실패 복구를 더 정교하게 다뤄야 한다. 하지만 학습 단계에서는 지금 형태로도 충분하다. 오히려 중요한 건 TypeScript에서 관리용 작업도 Tool로 드러낼 수 있다는 감각이다. 이 감각은 3부에서 운영 도구, 감사 로그, 관리 작업을 붙일 때 큰 힘이 된다.
stdio로 실행하고 Inspector로 확인하기
도구를 다 등록했으면 서버를 stdio로 띄운다.
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("PKM-MCP TypeScript server is running on stdio");
}
main().catch((error) => {
console.error(error);
process.exit(1);
});여기서 중요한 규칙은 하나다. stdio 서버에서는 stdout을 JSON-RPC가 독점한다. 그러니 디버그 로그를 console.log()로 찍지 말고 console.error()로 보내야 한다. 이건 사소한 팁처럼 보이지만, 초보자가 가장 자주 밟는 바나나 껍질이다.
실행은 이렇게 한다.
npm run build
node build/index.js그리고 Inspector로 확인하면 좋다.
npx @modelcontextprotocol/inspector node build/index.jsTypeScript SDK는 로컬 통합에는 stdio, 원격 서버에는 Streamable HTTP를 권장하고, 과거의 HTTP+SSE 방식은 하위 호환용으로만 남겨 두고 있다.4 이 차이가 왜 중요하냐면, Python의 FastMCP에서는 transport가 한 줄 옵션처럼 느껴질 때가 많지만, TypeScript 공식 SDK에서는 전송 계층이 더 또렷하게 보이기 때문이다. 덕분에 다음 장들에서 HTTP 모드로 넘어갈 때 머릿속 연결이 더 쉽다.
8.2 공식 TS SDK의 다른 점
이제 코드를 한 번 쭉 보고 나면, TypeScript 공식 SDK가 Python FastMCP와 무엇이 다른지 감이 잡힌다. 정리해 보면 네 가지가 특히 크다.
첫째, 스키마를 코드의 부속물이 아니라 중심에 둔다
Python에서는 타입 힌트와 docstring이 스키마로 승격된다. 아름답다. 하지만 TypeScript 공식 SDK에서는 스키마가 처음부터 눈앞에 놓인다. z.string().describe(...), z.number().min(...).default(...) 같은 표현이 도구 설계의 중심을 차지한다.
이 방식의 장점은 실수를 빨리 드러낸다는 점이다. 파라미터 설명이 빈약하면 코드가 허전해 보이고, 타입이 모호하면 에디터가 불편해하고, 반환 구조가 들쭉날쭉하면 후속 코드가 바로 저항한다. 말하자면 TypeScript는 개발자에게 "대충 쓰지 말고 계약서를 써라"라고 말한다.
둘째, 결과를 문자열이 아니라 객체로 생각하게 만든다
FastMCP에서는 문자열 반환만으로도 훌륭한 첫 서버를 만들 수 있다. TypeScript 공식 SDK에서는 content, structuredContent, isError 같은 키가 더 자주 눈에 들어온다. 즉, 결과를 "모델이 읽을 텍스트"와 "시스템이 다룰 데이터"로 나눠 생각하게 만든다.
이 차이는 지금 당장은 사소해 보여도, 나중에 UI를 붙이거나 로깅을 붙이거나 테스트를 만들 때 엄청난 차이를 만든다. 텍스트만 있으면 사람이 읽기는 쉽지만, 프로그램이 다루기 어렵다. 구조화된 결과가 있으면 둘 다 챙길 수 있다.
셋째, 전송 방식과 서버 경계를 더 분명하게 보여준다
공식 TypeScript SDK 문서를 보다 보면 stdio, Streamable HTTP, 세션, JSON 응답 모드, Express/Hono 같은 연결점이 꽤 또렷하게 보인다.4 이건 SDK가 차갑다는 뜻이 아니다. 오히려 "서버를 어디에, 어떻게 붙일 것인가"를 더 잘 드러낸다는 뜻이다.
이 특성은 3부로 갈수록 강점이 된다. 우리는 곧 stdio를 떠나 HTTP 모드로 갈 것이고, OAuth도 붙일 것이고, Cloudflare Workers에도 올릴 것이다. 그때 TypeScript 쪽 감각이 유난히 잘 맞는 이유가 바로 여기 있다.
넷째, 프레임워크를 얹을지 말지 판단이 쉬워진다
TypeScript 생태계에도 더 편한 상위 레이어가 있다. FastMCP for TypeScript는 Python FastMCP 같은 개발 경험을 목표로 하고, mcp-framework는 CLI, 자동 발견, 인증 같은 팀 친화 기능을 더 얹는다.56 즉, TypeScript라고 해서 꼭 공식 SDK의 다소 정직한 표정만 써야 하는 것은 아니다.
그럼에도 이번 장에서 공식 SDK를 택한 이유는 분명하다. 같은 서버를 다시 만들며 MCP를 더 깊게 이해하려면, 추상화 한 겹 아래를 보는 편이 좋기 때문이다. 빠르게 개발하는 데에는 상위 프레임워크가 유리할 수 있지만, 프로토콜 감각을 익히는 데에는 공식 SDK가 더 좋은 교과서다.
그래서 PKM-MCP는 어느 언어로 가져가야 할까
이제 현실적인 대답을 해보자. 정답은 "상황에 따라 다르다"가 맞지만, 그 말만 하면 너무 얄밉다. 그래서 조금 더 실용적으로 정리해 보겠다.
Python을 고르자. 이미 메모 처리, 임베딩, 데이터 분석, 실험 코드가 Python에 많고, 혼자 빠르게 프로토타입을 만들고 싶다면 가장 자연스럽다.
TypeScript를 고르자. 기존 서비스가 Node 생태계에 있고, 웹 백엔드나 프런트엔드 팀과 함께 움직이며, 후반부에 OAuth·Workers·UI까지 염두에 둔다면 점점 유리해진다.
공식 SDK를 고르자. 프로토콜을 배우고 싶거나, transport와 구조를 더 세밀하게 제어하고 싶다면 가장 선명하다.
상위 프레임워크를 고르자. 이미 MCP 감각이 잡혔고, 더 빠른 DX와 팀용 규칙이 필요하다면 그때는 FastMCP TS나
mcp-framework가 좋은 선택이 될 수 있다.
PKM-MCP 같은 메인 프로젝트에서는 사실 둘 다 해보는 게 가장 좋다. Python으로 시작하면 개념이 빨리 잡히고, TypeScript로 다시 만들면 운영의 그림이 보인다. 그러니 둘 중 하나를 버리는 선택이라기보다, 무엇을 더 빨리 배우고 싶은가의 선택에 가깝다.
같은 서버를 두 언어로 만들면 보이는 것
이번 장에서 우리는 똑같은 PKM-MCP를 TypeScript로 다시 세웠다. search_notes는 더 계약서 같은 도구가 되었고, semantic_search_notes는 결과 구조가 더 또렷해졌고, rebuild_note_index는 관리 작업까지 MCP의 일부로 끌어들였다.
겉으로 보면 기능은 비슷하다. 그런데 손맛은 다르다. Python은 "일단 되게 만들자"는 기운이 강하고, TypeScript는 "계속 버틸 수 있게 만들자"는 기운이 강하다. 둘 다 맞는 태도다. 다만 프로젝트의 단계에 따라 어울리는 순간이 다를 뿐이다.
PKM-MCP는 이제 언어를 바꿔도 살아남는 구조를 갖기 시작했다. 이건 꽤 중요한 변화다. 서버가 특정 프레임워크의 마법이 아니라, MCP라는 프로토콜 위에 서 있다는 뜻이기 때문이다.
다음 장에서는 여기서 한 걸음 더 나아가 외부 시스템을 붙인다. 로컬 메모만 잘 찾는 서버도 유용하지만, 진짜 재미는 GitHub나 데이터베이스, 기존 REST API 같은 바깥세상과 연결될 때 시작된다. 이제 PKM-MCP는 노트 앱을 넘어서, 작은 허브가 될 준비를 마쳤다.
참고
1MCP Server Frameworks and SDKs: A Developer's Guide
2modelcontextprotocol/typescript-sdk GitHub
3@modelcontextprotocol/sdk npm
4MCP TypeScript SDK server docs
5MCP Server Framework Comparison: TypeScript SDK vs FastMCP vs MCP-Go (2026)
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.