# TimelyGPT MCP Hub — 이식 핸드오프 가이드 (인간용)

> 작성: 2026-05-15 · 대상: 타임리(주) 백/프론트 엔지니어 · 작성자: 백상현 (마드라스체크)
>
> 이 문서를 5분 안에 훑으면 "어디부터 어떻게 끼울지"가 보입니다. 더 깊은 자동 이식용 인덱스는 같은 폴더의 [`/llms.txt`](../llms.txt)·[`/llms-full.txt`](../llms-full.txt)를 AI 에이전트에 던지세요.

---

## 1. 한 줄 요약

NestJS 11 백엔드(Hub)와 Next.js 15 시범 프론트(Web)로 구성된 MCP 도구 허브. **백엔드는 통째 이식, 프론트는 컴포넌트 단위 부분 이식**을 가정해 설계되어 있어요. 핵심 어댑터는 의도적으로 **프레임워크 비의존 pure-TS 모듈**로 분리해 둬서, 그쪽 프레임워크가 NestJS가 아니어도 복붙으로 옮겨갈 수 있습니다.

## 2. 2026-05-15 카톡 합의 (출처: 이호근 PM ↔ 백상현)

- **시연 시나리오 2개** 필수:
  1. **미팅 사전 브리핑** — 매일 아침 8시, 당일 일정 + 참석자 → 관련 이메일 요약 + 준비 아이템.
  2. **미회신 중요 업무 리마인드** — 매일 저녁 8시, 24시간 메일 전수 → 답장 누락 우선순위 리포트.
- 자연어로 스케줄 등록 → 실제 동작까지 영상에 담을 것.
- 슬랙은 데모 환경에 워크스페이스가 없어 **이번 영상에서는 제외**. Slack 통합은 그쪽 유저 시스템 붙고 나서 후속.
- 이메일 전수 윈도우는 **24시간** 기준.
- 완성 데드라인: 주말까지.

## 3. 이식 단위 — 어디까지 가져가야 하나

| 묶음 | 이식 | 형태 | 비고 |
|---|---|---|---|
| `src/common/` (errors, i18n, input-hints, solar, cache 등) | 🟢 통째 | pure-TS 또는 NestJS-bound | i18n·input-hints·reference·errors는 **0-dep pure TS** (Next/Express/Fastify 어디든 그대로 복붙 가능) |
| `src/modules/mcp/` (ToolRegistry, @McpTool 데코레이터, JSON-RPC controller) | 🟢 통째 | NestJS | 데코레이터는 NestJS DI에 묶여있음 — Nest가 아니면 등록 시점만 어댑팅 |
| `src/modules/tools/*` (19 atomic + 6 composite = 25개 도구) | 🟢 통째 | NestJS | 카테고리별 폴더, `@McpTool`로 자동 등록 |
| `src/modules/scheduler/` (BullMQ Repeatable + Worker + Notification + Delivery) | 🟢 통째 | NestJS + Redis | BullMQ 등 인프라 의존만 충족하면 그대로 동작 |
| `src/modules/composio/` (Gmail/Calendar/Slack/Notion/GitHub OAuth 브릿지) | 🟢 통째 | NestJS | Composio SDK 래퍼 |
| `apps/web/components/inputs/` (LocationPicker, LawdCodePicker, SidoSelector, DateSelector, EnumSelector, SmartArgField) | 🟡 부분 | React 19 / Tailwind 4 | 일반인 친화 UI (위경도 → 도시명 검색 등). 디자인 시스템에 맞게 손봐서 가져가세요 |
| `apps/web/components/errors/` (ErrorChip, FriendlyErrorBanner) | 🟡 부분 | React | 외부/입력/시스템 3분류 색칠. 디자인 토큰만 맞춰주면 됨 |
| `apps/web/app/api/*` (Next route handlers, Hub HTTP 프록시) | ❌ 참고만 | Next.js | 시연용 monorepo. 그쪽 BFF/프론트 라우팅 컨벤션에 맞게 다시 짜는 게 빠릅니다 |
| `apps/web/app/schedules,inbox,chat` 페이지 | ❌ 참고만 | Next.js | UX 참조용. 컴포넌트만 발췌해서 가져가세요 |
| `prisma/schema.prisma` (User, OAuthToken, Schedule, UsageLog, ToolConfig, Conversation, Message, Notification) | 🟢 통째 | PostgreSQL | 데이터 모델 그대로 권장 |

🟢 = 변경 없이 복붙 / 🟡 = 골라서 가져가서 디자인 토큰만 교체 / ❌ = 보고 다시 짜기.

## 4. 환경변수 (필수)

| 키 | 용도 | 비고 |
|---|---|---|
| `DATABASE_URL` | Postgres 18 (pgvector + SSL) | Railway Postgres-SSL 템플릿 권장 |
| `REDIS_URL` | Redis 7 (cache + BullMQ + Throttler) | |
| `PUBLIC_API_DATA_GO_KR_KEY` | 공공데이터포털 단일 API 키 | 19개 atomic 도구 공통 |
| `UPSTAGE_SOLAR_API_KEY` | Solar Pro 자연어 요약 | scheduler 결과 요약, composite Solar 후처리 |
| `COMPOSIO_API_KEY` | Composio L1 toolkit OAuth | gmail/calendar/slack/notion/github |
| `COMPOSIO_AUTH_CONFIG_GMAIL` | Composio gmail authConfigId | toolkit별 |
| `COMPOSIO_AUTH_CONFIG_GOOGLECALENDAR` | 동 calendar | |
| `COMPOSIO_AUTH_CONFIG_SLACK` | 동 slack | 후속 |
| `COMPOSIO_AUTH_CONFIG_NOTION` | 동 notion | |
| `COMPOSIO_AUTH_CONFIG_GITHUB` | 동 github | |
| `COMPOSIO_ENABLED` | `false`면 L1 도구·OAuth 차단 | **prod에서 본인 OAuth 노출 차단용. 다중 유저 환경에서는 반드시 유저별 토큰 분기 필요** |
| `DEMO_USER_ID` | 데모 단계 단일 사용자 | 다중 유저 시 채팅 세션 기반 분기 필요 |
| `UPSTAGE_SOLAR_MODEL` | default `solar-pro3` | |
| `UPSTAGE_SOLAR_BASE_URL` | default `https://api.upstage.ai/v1/solar` | |
| `COMPOSIO_TOOLKIT_VERSION` | 강제 override (옵션) | toolkit별 version은 자동 추출, 환경 강제 시만 |

## 5. 백엔드 5분 quickstart (Nest 환경 가정)

```bash
git clone <repo>
cd timelygpt
pnpm install
cp .env.example .env   # 위 환경변수 채우기
pnpm prisma migrate deploy
pnpm start:dev
# Hub: http://localhost:3000
# MCP JSON-RPC: POST http://localhost:3000/mcp
# tools/list: { "jsonrpc":"2.0", "id":1, "method":"tools/list" }
```

Nest가 아닌 환경(Express/Fastify/Hono 등)으로 옮길 때:

1. `src/modules/tools/*` 의 atomic 서비스들은 **순수 HTTP fetch + Zod**로만 짜여 있어 NestJS DI 한 줄 (`@Injectable()`)만 떼면 그대로 함수입니다.
2. `@McpTool` 데코레이터는 NestJS `Reflector` API에 묶여 있어요. 다른 환경에서는 **수동 등록 헬퍼**(`registry.register({name, description, inputSchema, handler})`)를 직접 호출하면 됩니다.
3. `src/common/i18n`, `src/common/input-hints`, `src/common/errors`는 **0-dep pure TS**라 그대로 복붙.

## 6. 프론트 부분 이식 가이드

타임리 자체 디자인 시스템이 이미 있다고 가정하면, **딱 다음 6개 컴포넌트만** 들고 가시면 됩니다:

| 컴포넌트 | 무엇을 해결하나 | 의존성 |
|---|---|---|
| `LocationPicker.tsx` | 위경도 입력을 "서울 강남" 자연어 검색으로 | `location-presets.ts` (41개 도시 시드 데이터) |
| `LawdCodePicker.tsx` | 5자리 법정동 코드(`11680`)를 한국어 fuzzy search로 | `/api/reference/lawd-codes` (250 entries, Cache-Control 1d) |
| `SidoSelector.tsx` | 17개 시도 드롭다운 | `/api/reference/sido` |
| `DateSelector.tsx` | yyyymm/yyyymmdd/iso/datetime-yyyymmddhhmm 4종 자동 변환 | HTML5 native input |
| `EnumSelector.tsx` | `AP01="환율"` 같은 labeled-enum | none |
| `SmartArgField.tsx` | hint 메타데이터 기반 자동 라우팅 | 위 5개 |

이식 흐름:

1. MCP `tools/list` 응답에 들어있는 `hints` 필드(vendor extension)를 그대로 활용. 백엔드 변경 없이 프론트만 가져가도 동작.
2. 각 컴포넌트 디자인 토큰(`border-neutral-200`, `bg-purple-50` 등)을 그쪽 디자인 시스템 토큰으로 search-replace.
3. `/api/reference/lawd-codes`, `/api/reference/sido` 두 엔드포인트는 백엔드 `src/common/reference/`에 ReferenceController로 떨어져 있어 그대로 마운트하면 됨.

에러 표시는 `ErrorChip`/`FriendlyErrorBanner` 2개로 처리:

- 응답 JSON에 `friendly: { category, title, message, retry }` 필드가 항상 따라옴.
- 색칠 규칙: external=주황, user=노랑, system=빨강.
- raw 메시지는 `VALIDATION_FAILED`/`MCP_INVALID_INPUT`/`SCHEDULE_INVALID_CRON`만 노출(`shouldAttachRaw` allow-list 기준).

## 7. 신규 시나리오 (2026-05-15 카톡 합의) — 어떻게 동작하나

### 미팅 사전 브리핑 (`morning_briefing`)

- 위치: `src/modules/tools/composite/morning-briefing/`
- 흐름: Google Calendar 당일 일정 → 참석자 email 추출 → 참석자별 24h Gmail thread fetch → Solar Pro 종합 요약 + 준비 아이템 bullet.
- 자연어 등록: 채팅 "스케줄 만들기" 토글 ON 후 *"매일 아침 8시 미팅 사전 브리핑"* 한 줄.
- 결과 모양: `{ date, events[], attendeeMailCounts, summary, preparationItems[], warnings[] }`.
- 부분 실패 허용: Calendar 실패 → 빈 events, Gmail 실패 → 0 카운트, Solar 실패 → fallback 메시지.

### 미회신 중요 업무 리마인드 (`unanswered_reminder`)

- 위치: `src/modules/tools/composite/unanswered-reminder/`
- 흐름: Gmail `in:inbox newer_than:1d` 전수 → `in:sent newer_than:1d`의 threadId와 매칭 → 답장 안 한 thread만 추출 → Solar Pro 우선순위 분류 (높음/보통/낮음).
- 자연어 등록: *"매일 저녁 8시 답장 안 한 메일 알려줘"*.
- noreply/notifications 도메인 자동 제외.
- 결과 모양: `{ windowHours, scannedInbox, scannedSent, unansweredCount, topItems[], summary, warnings[] }`.

### Slack 후속

카톡 합의대로 데모에는 제외. 코드 자리만 만들어 둠 (`COMPOSIO_AUTH_CONFIG_SLACK`, `CONNECTOR_SLUGS`에 slack 포함). 그쪽 유저 시스템에 Composio 사용자별 OAuth가 붙으면 `unanswered_reminder.service.ts`에 Slack thread fetch 한 블록만 추가하면 됩니다 (Solar 프롬프트는 그대로 OK).

## 8. 데이터베이스 마이그레이션

- Prisma 6, Postgres 18. 새 테이블(Notification, Schedule, ScheduleRun, UsageLog, OAuthToken 등) 포함.
- Railway 배포 시 `preDeployCommand: pnpm prisma migrate deploy`를 release-phase로 박아둬야 함 (HOTFIX #4 학습).
- 로컬 개발: `pnpm prisma migrate dev`.

## 9. FAQ / Troubleshooting

| 증상 | 원인 | 대응 |
|---|---|---|
| `/api/notifications/unread-count` 500 | Notification 테이블 미생성 | `pnpm prisma migrate deploy` |
| Composio 도구가 카탈로그에 안 보임 | `COMPOSIO_ENABLED=false` | 로컬은 true, prod은 다중 유저 OAuth 라우팅 붙은 뒤 활성화 |
| morning_briefing이 `warnings: ["Composio 비활성"]` | 위와 동일 | env 확인 |
| scheduler가 발화 안 함 | Redis 미연결 / BullMQ Repeatable key 충돌 | `REDIS_URL`, `redis-cli KEYS "bull:*"` 확인 |
| Solar 응답 빈 content | API quota 초과 / 모델명 변경 | `UPSTAGE_SOLAR_MODEL` 재확인, API key 잔량 |
| MCP `tools/list`에 hints 필드 없음 | 구버전 frontend | 최신 v1 — `t.hints` optional 필드, 없으면 schema fallback 동작 |

## 10. 영상·시연 자료

- 최신 영상: `apps/web/public/report/demo/scheduler/` (또는 `/tmp/c10-videos/c10-scheduler-demo-v3.mp4`)
- VTT 자막: 같은 폴더 `.vtt`.
- 시연 시나리오 7개 (SC01~SC07): empty → modal → drawer → inbox success → chat 자연어 등록 (Solar 실호출) → 1분 cron 발화 → 실패 케이스(ErrorChip).
- v3에서 morning_briefing / unanswered_reminder 자연어 등록 + 수동 실행 시연 추가됨.

## 11. 연락 / 다음 단계

| 항목 | 책임 | 비고 |
|---|---|---|
| Composio 다중 유저 OAuth 라우팅 | 타임리 측 | 그쪽 유저 시스템 붙은 뒤 |
| Slack 통합 (`unanswered_reminder`에 슬랙 합치기) | 타임리 측 | 위와 같은 타이밍 |
| `apps/web` 디자인 시스템 통합 | 타임리 프론트 | components/inputs, components/errors만 발췌 |
| 디자인 토큰 매핑 | 타임리 디자이너 | `border-purple-200` 등 → 자체 토큰 |
| 추가 atomic 도구 요청 | 백상현 ↔ 타임리 | PRD.md 수정 후 `/plan` |

---

자동 이식용 인덱스: [`/llms.txt`](../llms.txt) (네비) / [`/llms-full.txt`](../llms-full.txt) (전체)

학습 누적: [`/LEARNINGS.md`](../LEARNINGS.md)

PRD (immutable to agents, humans only): [`/specs/PRD.md`](./PRD.md)
