Skip to content

Latest commit

 

History

History
396 lines (269 loc) · 17.9 KB

File metadata and controls

396 lines (269 loc) · 17.9 KB

english-tutor-mcp — 설계 문서

로컬 MCP 서버. Claude와 영어로 대화하는 동안 사용자의 발화를 판단 없이 원문 그대로 수집하고, 하루 단위로 코퍼스 전체를 한 번에 분석해 반복되는 문법 패턴과 표현 문제를 마크다운 리포트로 뽑아낸다.


1. 설계 원칙

이 문서에서 벗어나고 싶어질 때 돌아와서 읽을 것. 아래 4개가 나머지 모든 결정의 근거다.

1.1 개별 문장이 아니라 패턴을 본다

문장 하나만 보면 "관사 하나 빠졌네"에서 끝난다. 40문장을 한꺼번에 놓고 보면 "추상명사 앞에서만 관사를 빠뜨린다"가 보인다. 후자가 이 도구의 존재 이유다. 따라서 분석은 반드시 하루치 코퍼스 전체를 한 번에 대상으로 한다.

1.2 낮에는 판단하지 않는다 (capture, don't judge)

대화 중에는 log_utterance만 호출한다. 교정도, 분류도, severity 판정도 하지 않는다.

  • 대화 중 모델은 "대화 상대" 모드라 사소한 어색함을 그냥 넘긴다. 이 시점에 판단하면 원재료 자체가 이미 빈약해진다.
  • 실시간 교정은 유창성 연습을 방해한다. 그게 이 도구를 쓰는 목적인데.

1.3 원문 보존이 생명이다

log_utterance에 들어가는 text사용자가 친 그대로여야 한다. 모델은 인용할 때 문법을 무의식적으로 고치는 습관이 강하다. 여기서 새면 DB에 깨끗한 영어만 쌓이고 분석할 게 없어진다.

이 프로젝트에서 가장 깨지기 쉬운 지점. M0에서 최우선으로 검증한다.

1.4 서버는 LLM을 호출하지 않는다

분석은 호스트 모델(Claude)이 한다. 서버는 SQLite CRUD만 한다.

  • API 키 설정 불필요 → 설치 마찰 거의 0
  • 사용자 비용 0
  • 서버 코드에 프롬프트 관리 로직 없음

대가로 크론(백그라운드 자동 분석)이 불가능하다. 이 제약을 우회하는 방법은 §7 참조.


2. 아키텍처

2.1 이건 "네트워크 서버"가 아니다

MCP에서 "서버"는 JSON-RPC 역할 이름일 뿐이다. 포트를 열지 않고, 데몬으로 상주하지 않는다.

사용자 노트북
├─ Claude Code
│    └─ spawn("npx", ["-y", "english-tutor-mcp"])   ← "MCP 서버"
│         stdin  ← {"method":"tools/call", ...}
│         stdout → {"result":{...}}
└─ ~/.english-tutor/
     ├─ db.sqlite
     └─ reports/2026-07-13.md

Claude Code가 켜지면 자식 프로세스로 뜨고, 꺼지면 같이 죽는다. Docker 없음. 호스팅 없음. 포트 없음. OAuth 없음. 데이터는 전부 사용자 로컬에만 존재한다.

2.2 데이터 흐름

낮 (대화 중)

사용자 영어 발화
  → 모델이 log_utterance 호출 (원문 그대로, 조용히)
  → SQLite utterances 테이블에 INSERT
  → 모델은 아무 말 없이 영어 대화 계속

밤 / 다음 세션 시작 시 (분석)

/report 또는 /tutor 진입
  → get_corpus(days) 호출 → 발화 전체 + taxonomy + 이전 findings 반환
  → 모델이 코퍼스 전체를 한 번에 분석해 패턴 추출
  → save_report(findings[], markdown) → DB + 파일 동시 저장

2.3 중요한 사실: 툴 호출은 강제할 수 없다

이 코드는 대화를 가로채지 않는다. 훅도 미들웨어도 없다. 모델이 자발적으로 툴을 호출해줄 때만 데이터가 들어온다.

Spring으로 치면, 만드는 건 Interceptor가 아니라 그냥 @Service 하나다. 그걸 부를지 말지는 컨트롤러(= 모델)가 결정하고, 그 컨트롤러 코드를 우리가 못 짠다. 자연어(server instructions)로 부탁만 할 수 있다.

이것이 이 프로젝트의 유일하고 치명적인 리스크다. 아키텍처가 아니라 확률 문제이므로 설계로 없앨 수 없다. 실측만이 답이다. → §8 M0


3. 기술 스택

항목 선택
언어 TypeScript
MCP SDK @modelcontextprotocol/sdk
스키마 검증 zod
DB better-sqlite3
트랜스포트 stdio
배포 npm (npx -y english-tutor-mcp)

Node를 고른 이유: npx 한 줄이라는 배포 채널이 이 프로젝트 UX의 절반이다. Java/Spring도 Spring AI MCP 스타터로 기술적으론 가능하지만, JVM 설치 요구 + jar 절대경로 관리 + 시작 지연이 채택률을 반토막 낸다. 도메인도 툴 4~5개 + SQLite CRUD 수준이라 Spring의 강점이 발휘될 복잡도가 아니다.

주의: MCP TS SDK가 패키지 재편 중이다. v1.x는 @modelcontextprotocol/sdk에서 McpServer + StdioServerTransport, 새 라인은 @modelcontextprotocol/server에서 serveStdio()를 쓴다. 착수 전 npm에서 현재 안정 버전을 확인하고 하나로 고정할 것.


4. 데이터 모델

CREATE TABLE sessions (
  id           INTEGER PRIMARY KEY,
  started_at   TEXT NOT NULL,
  topic        TEXT,
  analyzed_at  TEXT              -- NULL이면 아직 분석 안 됨 (§7 자동 분석의 핵심)
);

-- 원재료: 판단 없이 원문만
CREATE TABLE utterances (
  id          INTEGER PRIMARY KEY,
  session_id  INTEGER NOT NULL REFERENCES sessions(id),
  created_at  TEXT NOT NULL,
  text        TEXT NOT NULL,     -- 사용자가 친 영어 원문 그대로. 절대 정규화 금지.
  context     TEXT               -- 직전 assistant 발화
);

-- 분석 결과: 이 프로젝트의 진짜 자산
CREATE TABLE findings (
  id           INTEGER PRIMARY KEY,
  report_id    INTEGER NOT NULL REFERENCES reports(id),
  category     TEXT NOT NULL,    -- 'article/zero-article'
  severity     TEXT NOT NULL,    -- 'error' | 'awkward' | 'upgrade'
  pattern      TEXT NOT NULL,    -- "추상명사 앞에서 관사를 빠뜨림"
  evidence     TEXT NOT NULL,    -- JSON 배열: 근거가 된 utterance id들
  suggestion   TEXT NOT NULL,
  occurrences  INTEGER NOT NULL  -- 그 기간 내 반복 횟수
);

CREATE TABLE reports (
  id            INTEGER PRIMARY KEY,
  period_start  TEXT NOT NULL,
  period_end    TEXT NOT NULL,
  path          TEXT NOT NULL,
  created_at    TEXT NOT NULL
);

CREATE INDEX idx_utterances_session ON utterances(session_id);
CREATE INDEX idx_findings_category  ON findings(category);

왜 이렇게 생겼는지

utterances.context — 이게 없으면 "그 표현이 상황에 맞았나"를 판단할 수 없다. "I'm good."은 그 자체로 완벽한 문장이지만, 상대가 "How was the trip?"이라 물었다면 동문서답이다. 원문만 저장하면 이런 걸 영영 못 잡는다.

findings.evidence — 근거 utterance id를 반드시 남긴다. "너 관사 자주 빠뜨려"보다 "이 4문장에서 빠뜨렸어"가 학습 효과가 압도적이고, 리포트에 실제 문장을 인용할 수 있다.

findings.category는 컬럼 — JSON blob에 뭉뚱그리면 나중에 집계할 때 후회한다.

sessions.analyzed_at — 자동 분석 트리거의 핵심 (§7).


5. 툴 스펙

인자 반환 호출 시점
start_session topic? session_id /tutor 진입 시
log_utterance text, context?, session_id "ok" (최소) 대화 중 매 영어 발화마다
get_pending_corpus 미분석 세션 코퍼스 + taxonomy + 최근 findings /tutor 진입 시
get_corpus days=1 코퍼스 + taxonomy + 이전 findings /report
save_report findings[], markdown 저장 경로 분석 직후
get_weak_points days=30 findings 집계 /weak-points

설계 노트

get_corpus가 이전 findings도 함께 반환하는 게 핵심이다. 그래야 모델이 "이 지적은 3주째 반복 중"이라고 쓸 수 있다. 이게 없으면 매일의 리포트가 서로 무관한 독립 문서가 되고, "내가 뭐가 약한지"라는 원래 목적이 달성되지 않는다.

log_utterance의 반환값은 극단적으로 짧게. 저장한 문장을 되돌려주는 에코는 절대 금지 — 토큰이 2배가 된다. "ok" 또는 빈 문자열이면 충분하다.

save_report는 트랜잭션이다. findings INSERT와 마크다운 파일 쓰기가 함께 성공하거나 함께 실패해야 한다.

저장 시점에 category를 검증하지 않는다. 어차피 저장할 땐 분류를 안 하기 때문이다. taxonomy는 분석 시점의 렌즈로만 쓰인다.


6. Taxonomy

src/taxonomy.ts에 상수로 두고, get_corpus / get_pending_corpus 응답에 통째로 실어 보낸다. 서버가 강제하는 스키마가 아니라, 모델이 분석할 때 참조하는 렌즈다.

tense/        present-perfect, past-simple, future, conditional
article/      definite, indefinite, zero-article
preposition/  time, place, phrasal-verb
agreement/    subject-verb, plural
structure/    relative-clause, word-order, run-on, fragment
word-choice/  collocation, register, false-friend

2단계 구조로 20~30개 수준을 유지한다. 너무 성기면 리포트가 "관사 오류 12건"처럼 쓸모없어지고, 너무 세분화하면 모델이 일관되게 못 붙인다.

severity 3단계

의미
error 문법적으로 틀림
awkward 틀리진 않았지만 원어민은 그렇게 안 씀
upgrade 맞지만 더 나은 표현이 있음

섞이면 안 된다. 섞이는 순간 리포트에서 "틀린 것"과 "더 나은 것"의 우선순위가 사라진다.


7. 자동 분석 — 크론 없이 하기

문제

크론은 새벽 3시에 깬다. 그때 Claude Code는 꺼져 있고, MCP 서버는 호스트에게 먼저 말을 걸 수 없다(단방향). 즉 "서버에 LLM 없음"(§1.4)과 "진짜 크론"은 동시에 만족 불가능하다.

해법: 트리거를 다음 세션 시작으로 옮긴다

/tutor로 세션을 열 때, get_pending_corpusanalyzed_at IS NULL인 세션들을 반환한다. 미분석 코퍼스가 있으면 대화 시작 전에 먼저 분석·저장한다.

/tutor 프롬프트 흐름:
  1. get_pending_corpus() 호출
  2. 미분석 세션이 있으면 → 분석 → save_report() → 사용자에게 한 줄 요약만
  3. 영어 대화 시작

이게 크론보다 나은 이유:

  • 서버에 LLM 불필요 → 설치 마찰 0 유지
  • 사용자는 그냥 매일 /tutor만 치면 됨. 리포트는 저절로 생김
  • 어제 리포트를 방금 읽은 상태로 오늘 대화가 시작된다. 모델이 "어제 관사 문제 있었으니 오늘 그 부분을 눈여겨보자"고 이어갈 수 있어 학습 루프가 오히려 개선됨

트레이드오프:

  • 대화를 3일 안 하면 3일치가 밀렸다 한 번에 처리됨 (연습 안 한 날은 분석할 것도 없으니 자연스러움)
  • 세션 시작이 몇 초 느려짐

진짜 크론이 필요하면 (v2 옵션)

ANTHROPIC_API_KEY 환경변수가 있으면 크론 모드를 켜고, 없으면 위 방식으로 동작하게 한다. 코어 로직(코퍼스 → 분석 프롬프트 → findings)은 동일하고 "누가 LLM을 부르느냐"만 갈아끼우는 구조로 짜둘 것. 기본 경로는 반드시 키 없이 돌아가야 한다.


8. 마일스톤

M0 — 스파이크 (1~2일) · 이걸 먼저 안 하면 전부 헛수고

목적은 코드가 아니라 가설 검증이다. 검증할 것 딱 두 개:

  1. 모델이 대화 중에 log_utterance를 실제로 부르는가? — 30분 영어 대화 후, 실제 발화 수 대비 DB 행 수를 센다.
  2. 원문이 그대로 저장되는가? — 저장된 문장에 문법 오류가 살아있는지 눈으로 확인. 모델이 고쳐서 넘겼으면 설계가 근본부터 바뀌어야 한다.

만들 것: log_utterance 하나 + SQLite + stdio 서버. 그게 전부.

실패 시 대비책: 세션 끝에 /wrap-up으로 오늘 발화 전체를 배치로 넘기는 방식(log_session(utterances[])). 호출 1회라 누락 여지가 적다. 단, 모델이 히스토리에서 문장을 옮겨 적을 때 무의식적으로 고칠 위험이 새로 생기므로 이것도 실측 필요.

M1 — 도메인 (여기가 제품의 본체)

Taxonomy 확정, DB 스키마 확정, 코어 서비스 레이어. 툴 핸들러에 비즈니스 로직을 직접 쓰지 말 것 — 코어 서비스만 호출하게 한다. 나중에 stdio → HTTP 전환 시 어댑터만 갈아끼우면 되도록.

src/
├─ core/        # 도메인 로직 (트랜스포트 무관)
├─ db/          # better-sqlite3, 마이그레이션
├─ mcp/         # 툴 핸들러 = 코어 호출 + 스키마 검증뿐
├─ taxonomy.ts
└─ instructions.ts

M2 — MCP 서버

툴 6개 구현, stdio 트랜스포트, DB 경로는 ~/.english-tutor/ 기본 + 환경변수 오버라이드.

M3 — 사용성 레이어 (오픈소스 성패가 여기서 갈림)

남의 클라이언트엔 지시문을 심을 수 없으므로, 서버가 스스로 사용법을 알려줘야 한다.

  • server instructions (§9) — 설치만 하면 자동 적용
  • prompts (§10) — Claude Code에서 슬래시 커맨드로 노출

M4 — 리포트

마크다운 렌더링, ~/.english-tutor/reports/YYYY-MM-DD.md 저장.

M5 — 패키징

bin 설정 → npm publish → README에 클라이언트별 config 스니펫 (Claude Code / Desktop / Codex / Cursor) → MCP Registry 등록.

M6 — OSS 위생

MIT LICENSE, CI, CONTRIBUTING, 데모 GIF. README 상단 30초 안에 "이게 뭐고 어떻게 켜는지"가 보여야 한다 — 오픈소스는 사실상 README가 제품이다.


9. Server instructions

initialize 응답의 instructions 필드. 스펙상 "서버 사용법을 설명하는 힌트"이고, 클라이언트가 이를 시스템 프롬프트에 넣을 수 있다. §1.2와 §1.3이 여기 전부 걸려 있다.

You are an English conversation partner. Speak naturally, at a level
slightly above the user's. Keep the conversation flowing.

DO NOT correct the user's English during conversation. Corrections
break fluency practice, which is the entire point of this tool.
Analysis happens later, in batch.

After every user message written in English, silently call
log_utterance with:
  - text: their message VERBATIM. Never fix, normalize, clean up, or
    paraphrase it. The errors ARE the data. A corrected sentence is a
    destroyed sample.
  - context: your immediately preceding message.

Log every English utterance, including flawless ones — the corpus
needs a baseline. Do not mention that you are logging. Do not echo or
summarize what you logged.

Analysis happens only through the `report` prompt or at the start of a
`tutor` session. Never analyze ad hoc.

instructions 반영 여부는 스펙상 클라이언트 재량(MAY)이다. 여기에만 의존하지 말 것. M0에서 실제 반영되는지 확인한다.


10. Prompts (슬래시 커맨드)

프롬프트 동작
/tutor [topic] get_pending_corpus → 밀린 분석 처리 → 세션 시작 → 영어 대화
/report [days] get_corpus → 분석 → save_report
/weak-points 최근 30일 findings 조회 (리포트 생성 없이 조회만)

/report 프롬프트 본문에 반드시 넣을 지침

  • 반복 3회 미만인 항목은 리포트에서 제외한다. 이걸 안 넣으면, 피하고 싶었던 "문장 하나하나 피드백"이 리포트 안에서 부활한다.
  • 이전 findings와 대조해 반복 여부를 명시한다 ("3주째 반복").
  • 각 finding에 실제 발화를 근거로 인용한다.
  • errorawkwardupgrade 순으로 우선순위를 매긴다.

리포트 구조

# 2026-07-13 영어 리포트
발화 42개 · 세션 1개

## 반복되는 문법 패턴
### 1. 현재완료 자리에 과거형 (5회, error) — 3주째 반복
- "I have went to Busan last week." → "I went to Busan last week."
- ...
**규칙**: (설명)

## 더 나은 표현
...

## 이번 주 추이

11. 토큰 / 비용

구간 추가 비용
log_utterance 1회 100150 토큰
하루 40턴 ~5k 토큰
리포트 1회 ~10k 토큰

실질적으로 무시 가능하다. 진짜 비용은 "Claude와 영어로 30분 대화하는 것" 자체이고, 로깅은 거기 얹히는 5% 수준이다.

지킬 것 3가지

  1. 툴 응답 최소화 — 유일하게 의미 있는 최적화. 에코 금지.
  2. get_corpusdays를 크게 잡지 말 것 — 30일치를 한 번에 넣으면 그때야 진짜 비싸진다.
  3. /report는 새 세션에서 실행 — 대화 40턴이 누적된 컨텍스트 위에 코퍼스를 얹지 않는다. 비용보다 품질 때문에 더 중요하다 — 대화 기억에 오염되지 않고 순수하게 텍스트만 보게 되므로 분석이 정확해진다. README에 명시할 것.

12. 리스크

리스크 심각도 대응
모델이 log_utterance를 안 부름 치명 M0에서 실측. 실패 시 /wrap-up 배치 방식으로 전환
모델이 원문을 고쳐서 넘김 치명 M0에서 실측. instructions에 최대한 강하게 명시
instructions를 클라이언트가 무시 prompts로 보완. /tutor 본문에 규칙 재주입
리포트가 뻔하고 안 읽힘 M0 이후 2주 실사용으로 검증

모든 치명 리스크가 M0에서 판명된다. 그래서 M0가 먼저다.


13. 범위 밖 (v1에서 하지 않는 것)

  • 원격 HTTP 서버, OAuth, 멀티테넌시 — 셀프호스트라 불필요
  • 이메일 발송 — 개발자 타겟에겐 로컬 마크다운이 더 낫다
  • ChatGPT Apps SDK의 UI 위젯 — OpenAI 전용이라 크로스 클라이언트 이식성을 깬다
  • 서버 측 LLM 호출 — ANTHROPIC_API_KEY 옵션으로 v2에서 검토

타겟 사용자는 "영어 공부하는 개발자"다. 일반 영어 학습자가 아니다. npx / config / MCP를 아는 사람들이고, 이 전제가 위 결정 전부를 정당화한다.