나만의 MCP Server 만들기: Claude Code 전용 기능을 직접 구축하기

TypeScript로 프로젝트 컨텍스트 관리용 MCP Server를 처음부터 구축한다. 의사결정 기록, 태스크 관리, 세션 간 검색을 구현하고 도구 등록, Resource, 디버깅, 배포까지 다룬다.


지금까지의 MCP 글 세 편은 모두 기존에 있는 것을 연결하는 내용이었다——이미 만들어진 MCP Server, 데이터베이스, 내부 API. 이번 글은 다르다. MCP Server를 처음부터 직접 만들어서 기존 도구로는 할 수 없는 일을 시킬 것이다.

남의 API를 래핑하는 게 아니라, 로직을 직접 구현한다.

만들 것: 프로젝트 컨텍스트 매니저

Claude Code에는 실질적인 문제가 하나 있다. 세션 간 기억이 없다는 점이다. 새 세션을 열 때마다 프로젝트 배경, 최근 결정 사항, 진행 중인 작업을 다시 알려줘야 한다. CLAUDE.md로 일부는 해결할 수 있지만, 정적 파일이라 프로젝트가 진행되면서 자동으로 업데이트되지 않는다.

다음과 같은 일을 할 수 있는 MCP Server를 만들어 보자:

  • 프로젝트 결정 사항과 컨텍스트 기록 (「캐시에 Redis를 쓰기로 했다. 이유는 X」)
  • TODO와 진행 중인 작업 관리
  • 새 세션에서 이전에 기록한 컨텍스트 검색

데이터는 로컬 JSON 파일에 저장하며, 외부 서비스에 의존하지 않는다.

프로젝트 초기화

mkdir mcp-project-context && cd mcp-project-context
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

package.json"type": "module"을 추가한다.

데이터 레이어

먼저 데이터 읽기/쓰기를 구현한다. 모든 데이터는 프로젝트 루트의 .claude/project-context.json에 저장한다.

src/store.ts:

import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { dirname } from "path";

export interface Decision {
  id: string;
  title: string;
  content: string;
  tags: string[];
  createdAt: string;
}

export interface Task {
  id: string;
  title: string;
  status: "todo" | "in-progress" | "done";
  notes: string;
  createdAt: string;
  updatedAt: string;
}

export interface ProjectContext {
  decisions: Decision[];
  tasks: Task[];
}

export class Store {
  private data: ProjectContext;

  constructor(private filePath: string) {
    this.data = this.load();
  }

  private load(): ProjectContext {
    if (!existsSync(this.filePath)) {
      return { decisions: [], tasks: [] };
    }
    return JSON.parse(readFileSync(this.filePath, "utf-8"));
  }

  private save(): void {
    const dir = dirname(this.filePath);
    if (!existsSync(dir)) {
      mkdirSync(dir, { recursive: true });
    }
    writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
  }

  // Decisions
  addDecision(title: string, content: string, tags: string[]): Decision {
    const decision: Decision = {
      id: crypto.randomUUID().slice(0, 8),
      title,
      content,
      tags,
      createdAt: new Date().toISOString(),
    };
    this.data.decisions.push(decision);
    this.save();
    return decision;
  }

  searchDecisions(query: string): Decision[] {
    const q = query.toLowerCase();
    return this.data.decisions.filter(
      (d) =>
        d.title.toLowerCase().includes(q) ||
        d.content.toLowerCase().includes(q) ||
        d.tags.some((t) => t.toLowerCase().includes(q))
    );
  }

  listDecisions(): Decision[] {
    return this.data.decisions;
  }

  // Tasks
  addTask(title: string): Task {
    const task: Task = {
      id: crypto.randomUUID().slice(0, 8),
      title,
      status: "todo",
      notes: "",
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
    this.data.tasks.push(task);
    this.save();
    return task;
  }

  updateTask(
    id: string,
    updates: Partial<Pick<Task, "status" | "notes" | "title">>
  ): Task | null {
    const task = this.data.tasks.find((t) => t.id === id);
    if (!task) return null;
    Object.assign(task, updates, { updatedAt: new Date().toISOString() });
    this.save();
    return task;
  }

  listTasks(status?: Task["status"]): Task[] {
    if (status) {
      return this.data.tasks.filter((t) => t.status === status);
    }
    return this.data.tasks;
  }
}

몇 가지 설계 포인트:

  • ID는 UUID 앞 8자리를 사용한다. 구두로 참조할 수 있을 만큼 짧다 (「태스크 a3f2b1c9 업데이트해줘」)
  • 쓰기 작업마다 즉시 영속화하며, 메모리 캐시는 하지 않는다
  • 데이터 구조는 의도적으로 단순하게 유지한다. 필요한 만큼이면 충분하다

도구 등록

src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { Store } from "./store.js";

const CONTEXT_FILE =
  process.env.CONTEXT_FILE || ".claude/project-context.json";
const store = new Store(CONTEXT_FILE);

const server = new McpServer({
  name: "project-context",
  version: "1.0.0",
});

// ---- 의사결정 관리 ----

server.tool(
  "record_decision",
  "記録一条项目决策。当团队做出技术选型、架构决定、或重要的设计取舍时使用",
  {
    title: z.string().describe("決策標題,如「缓存方案选型」"),
    content: z.string().describe("決策内容和原因"),
    tags: z
      .array(z.string())
      .optional()
      .default([])
      .describe("标签,便于后续检索,如 [\"cache\", \"architecture\"]"),
  },
  async ({ title, content, tags }) => {
    const decision = store.addDecision(title, content, tags);
    return {
      content: [
        {
          type: "text",
          text: `已记录决策 [${decision.id}]: ${decision.title}`,
        },
      ],
    };
  }
);

server.tool(
  "search_decisions",
  "搜索已记录的项目决策。当需要回顾之前为什么做某个选择时使用",
  {
    query: z.string().describe("搜索关键词,会匹配标题、内容和标签"),
  },
  async ({ query }) => {
    const results = store.searchDecisions(query);
    if (results.length === 0) {
      return {
        content: [{ type: "text", text: `没有找到与「${query}」相关的决策` }],
      };
    }
    return {
      content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
    };
  }
);

server.tool(
  "list_decisions",
  "列出所有已记录的项目决策",
  {},
  async () => {
    const decisions = store.listDecisions();
    return {
      content: [{ type: "text", text: JSON.stringify(decisions, null, 2) }],
    };
  }
);

// ---- 태스크 관리 ----

server.tool(
  "add_task",
  "添加一个待办任务",
  {
    title: z.string().describe("任务标题"),
  },
  async ({ title }) => {
    const task = store.addTask(title);
    return {
      content: [
        { type: "text", text: `已添加任务 [${task.id}]: ${task.title}` },
      ],
    };
  }
);

server.tool(
  "update_task",
  "更新任务的状态或备注",
  {
    id: z.string().describe("任务 ID"),
    status: z
      .enum(["todo", "in-progress", "done"])
      .optional()
      .describe("新状态"),
    notes: z.string().optional().describe("备注信息"),
    title: z.string().optional().describe("更新标题"),
  },
  async ({ id, status, notes, title }) => {
    const task = store.updateTask(id, { status, notes, title });
    if (!task) {
      return {
        content: [{ type: "text", text: `找不到任务 ${id}` }],
        isError: true,
      };
    }
    return {
      content: [{ type: "text", text: JSON.stringify(task, null, 2) }],
    };
  }
);

server.tool(
  "list_tasks",
  "列出任务。可以按状态筛选",
  {
    status: z
      .enum(["todo", "in-progress", "done"])
      .optional()
      .describe("按状态筛选,不传则列出全部"),
  },
  async ({ status }) => {
    const tasks = store.listTasks(status);
    return {
      content: [{ type: "text", text: JSON.stringify(tasks, null, 2) }],
    };
  }
);

// ---- 시작 ----

const transport = new StdioServerTransport();
server.connect(transport);

Resource 등록

도구 외에도 MCP는 Resource를 지원한다——Claude가 능동적으로 읽을 수 있는 데이터다. 「프로젝트 개요」를 Resource로 등록하면, Claude가 세션 시작 시 전체 컨텍스트를 가져올 수 있다:

server.resource("project-summary", "project://summary", async (uri) => {
  const decisions = store.listDecisions();
  const tasks = store.listTasks();
  const activeTasks = tasks.filter((t) => t.status !== "done");

  const summary = {
    totalDecisions: decisions.length,
    recentDecisions: decisions.slice(-5),
    activeTasks,
    completedTasks: tasks.filter((t) => t.status === "done").length,
  };

  return {
    contents: [
      {
        uri: uri.href,
        mimeType: "application/json",
        text: JSON.stringify(summary, null, 2),
      },
    ],
  };
});

Resource와 Tool의 차이: Tool은 대화 중에 Claude가 필요에 따라 호출하는 것이다. Resource는 Claude가 읽을 수 있는 정적 컨텍스트로, CLAUDE.md와 비슷하지만 동적으로 생성할 수 있다.

빌드 및 설정

npx tsc

Claude Code 설정:

{
  "mcpServers": {
    "project-context": {
      "command": "node",
      "args": ["/path/to/mcp-project-context/dist/index.js"],
      "env": {
        "CONTEXT_FILE": "/path/to/your-project/.claude/project-context.json"
      }
    }
  }
}

Claude Code를 재시작하고, /mcp를 입력해서 project-context가 목록에 나타나는지 확인한다.

실제 사용법

설정을 마치면, Claude와의 대화는 이런 식으로 진행된다:

의사결정 기록

방금 논의를 마쳤는데, 태스크 큐로 Bull + Redis를 쓰기로 했어. RabbitMQ는 안 쓸 거야.
주된 이유는 팀에서 이미 Redis를 쓰고 있어서 새로운 인프라를 추가하고 싶지 않기 때문이야.
기록해줘.

→ Claude가 record_decision을 호출
→ 결정 기록 완료 [a3f2b1c9]: 태스크 큐 선정: Bull + Redis

세션 간 검색

새 세션에서:

전에 Bull을 선택하고 RabbitMQ를 안 쓴 이유가 뭐였지?

→ Claude가 search_decisions("Bull")을 호출
→ 이전 기록을 찾아서 결정 이유를 상세히 설명

태스크 관리

다음에 할 일이 3가지야:
1. 사용자 등록 API 구현
2. 등록 이메일 템플릿 작성
3. 이메일 인증 플로우 추가

→ Claude가 add_task를 3번 호출
→ 3개의 태스크가 각각 생성되고, 상태는 모두 todo
등록 API 다 만들었어, 상태 업데이트해줘

→ Claude가 update_task(id, "done")을 호출
→ 동시에 나머지 todo 태스크 목록을 표시

MCP Server 개발 시 주의할 점

이 Server를 만들면서 정리한 실전 경험을 몇 가지 공유한다.

에러 처리는 친절하게

MCP 도구가 에러를 반환하면, Claude는 그 에러 메시지를 사용자에게 그대로 전달한다. 그러니 에러 메시지는 사람이 읽을 수 있게 작성해야 한다:

// 나쁜 예
throw new Error("ENOENT");

// 좋은 예
return {
  content: [{ type: "text", text: `找不到任务 ${id},用 list_tasks 查看所有任务` }],
  isError: true,
};

isError: true는 Claude에게 이번 호출이 실패했음을 알려주고, Claude는 이에 따라 후속 동작을 조정한다 (예를 들어 다른 방식으로 재시도).

도구 수는 너무 많지 않게

하나의 MCP Server가 노출하는 도구는 5~10개가 적당하다. 15개를 넘으면 Claude가 잘못된 도구를 선택할 확률이 눈에 띄게 올라간다. 기능이 너무 많다면 여러 Server로 나누자.

반환값에 정보를 담아라

도구가 「작업 성공」만 반환하는 것은 부족하다. Claude는 작업 결과가 무엇인지 알아야 대화를 이어갈 수 있다:

// 나쁜 예
return { content: [{ type: "text", text: "OK" }] };

// 좋은 예
return { content: [{ type: "text", text: `已添加任务 [${task.id}]: ${task.title}` }] };

zod로 파라미터 검증하기

MCP SDK는 zod를 네이티브로 지원한다. zod로 파라미터를 정의하면 타입 체크뿐만 아니라 .describe()로 Claude에게 사용법을 알려줄 수 있다. 설명이 없는 파라미터는 Claude가 추측할 수밖에 없다.

stdio가 가장 간단한 전송 방식

MCP는 두 가지 전송 방식을 지원한다: stdio(표준 입출력)와 HTTP+SSE. 로컬 개발용 MCP Server라면 stdio로 충분하다. Claude Code가 Server를 자식 프로세스로 실행하고 stdin/stdout으로 통신한다.

HTTP+SSE는 원격 배포된 Server에 적합하다. 예를 들어 사내에서 통합 제공하는 MCP 서비스 같은 경우다.

디버깅 방법

MCP Inspector 사용

Anthropic은 MCP Inspector라는 디버깅 도구를 제공한다. 브라우저에서 MCP Server를 직접 테스트할 수 있다:

npx @modelcontextprotocol/inspector node dist/index.js

웹 인터페이스가 실행되어 각 도구를 수동으로 호출하고, 입출력을 확인하고, 에러를 점검할 수 있다. Claude Code에서 반복적으로 테스트하는 것보다 훨씬 효율적이다.

로그 추가

MCP Server의 stdout은 점유되어 있으므로(JSON-RPC 통신에 사용) console.log를 쓸 수 없다. 디버그 정보는 console.error로 출력하면 stderr에 기록된다:

console.error("[debug] processing request:", JSON.stringify(params));

또는 파일에 기록한다:

import { appendFileSync } from "fs";

function log(msg: string) {
  appendFileSync("/tmp/mcp-debug.log", `${new Date().toISOString()} ${msg}\n`);
}

배포 및 배포

MCP Server가 안정화되면 몇 가지 배포 방법이 있다:

npm 패키지

bin 필드와 shebang을 추가하고 npm에 배포한다:

{
  "name": "@yourcompany/mcp-project-context",
  "bin": {
    "mcp-project-context": "./dist/index.js"
  }
}

dist/index.js 맨 위에 추가:

#!/usr/bin/env node

사용할 때:

{
  "mcpServers": {
    "project-context": {
      "command": "npx",
      "args": ["-y", "@yourcompany/mcp-project-context"]
    }
  }
}

Docker

Server에 외부 의존성(데이터베이스, Redis)이 있다면 Docker로 패키징하는 게 더 깔끔하다:

{
  "mcpServers": {
    "project-context": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "yourcompany/mcp-project-context"]
    }
  }
}

-i 옵션에 주의——stdio 전송에서는 stdin을 열어둬야 한다.

다음 단계

이 글의 예제는 최소한이지만 완전한 MCP Server다. 이것을 기반으로 다양한 방향으로 확장할 수 있다:

  • 프로젝트 타임라인 추가 (매일 무엇을 했는지 자동 기록)
  • git log 연동 (커밋 이력과 의사결정을 연결)
  • 멀티 프로젝트 지원 (하나의 Server로 여러 프로젝트의 컨텍스트 관리)
  • prompt 템플릿 추가 (예를 들어 「프로젝트 일일 보고서」의 생성 형식을 미리 정의)

MCP의 가능성은 상상력에 달려 있다. 코드로 작성할 수 있는 일이라면, 무엇이든 Claude의 도구로 만들 수 있다.