<!-- canonical: https://docs.axelabs.ai/services/blueprint -->
<!-- source: content/services/blueprint.mdx -->

---
title: Blueprint
description: AI-native workspace, Claude Agent SDK, MCP 클라이언트, 멀티테넌트 web UI.
---

# Blueprint

**한 줄 소개**: 인간 운영자와 AI 에이전트가 함께 일하는 통합 워크스페이스. Next.js + Claude Agent SDK 로 13종 에이전트 실행, frame · stream · magnet MCP 서버를 도구로 호출.

## 기술 스택

| 항목 | 값 |
|---|---|
| 언어 | TypeScript |
| 프레임워크 | Next.js 16 (App Router) + React 19 |
| 스타일 | Tailwind CSS 4 + `@axe/ui` 디자인 토큰 (`/Users/axe/axelabs/src/lib/`, sync 됨 → `src/app/_axe-ui/`) |
| DB | PostgreSQL 16 (Prisma 6.19) — D-config-17 cutover 2026-05-15. SQLite 는 historical (cutover 이전 `prisma/data/blueprint.db`) |
| 인증 | NextAuth 4 + Microsoft Entra ID (Azure AD) |
| 에이전트 | `@anthropic-ai/claude-agent-sdk` 0.2.92 |
| 실시간 | Socket.IO 4.8 (port 3101 WebSocket) |
| 터미널 | node-pty 1.1 (PTY) |
| MCP 클라이언트 | `@anthropic-ai/sdk` 0.82 (HTTP/SSE transport) |

## 포트

| 포트 | 용도 |
|---|---|
| 3100 | Next.js dev / production HTTP |
| 3101 | Socket.IO WebSocket |
| 3180 | Caddy 외부 노출 (HTTPS) |

## 디렉토리 구조

```
/Users/axe/blueprint/
├── src/
│   ├── app/                    Next.js App Router
│   │   ├── layout.tsx
│   │   ├── page.tsx           landing
│   │   ├── onboard/           가입 흐름
│   │   ├── axe/               대시보드 (admin, agents, issues, projects, skills, team, trinity)
│   │   └── api/               93+ route.ts
│   │       ├── auth/[...nextauth]
│   │       ├── agents/
│   │       ├── sessions/
│   │       ├── workspaces/
│   │       ├── graph/         Microsoft Graph API proxy
│   │       └── teams-graph-*  Teams 통합
│   ├── lib/
│   │   ├── auth.ts            NextAuth + Azure AD
│   │   ├── anthropic-client.ts
│   │   ├── customers.ts       customers.yaml reader (mtime cache)
│   │   ├── mcp/
│   │   │   └── frame.ts       frame HTTP MCP 등록, ~21개 tool wrapping
│   │   ├── agents/
│   │   │   └── execution-service.ts
│   │   └── teams/
│   ├── server/
│   │   └── websocket.ts       Socket.IO + PTY (port 3101)
│   └── prisma/
│       └── schema.prisma      Member / User / Agent / Session / Issue
├── docker/
│   └── docker-compose.yml
└── docs/
    ├── project-blueprint.md   47KB master doc (5 분과)
    └── project-blueprint/
        ├── division1-org.md
        ├── division2-tech.md
        ├── division3-ops.md
        ├── division4-claude-code.md
        └── division5-risk-governance.md
```

## 데이터 모델 (요약)

```
Entity (axec / axev / sys — D-bp-entity-3 NOT NULL)
  ├─ slug              UNIQUE (axec | axev | sys | …)
  ├─ name              한국어 정식 명칭 (hive shared.entity.legal_name mirror, D-bp-entity-4)
  ├─ kind              "corporate" | "system" | "fund" | …
  ├─ bizNo             사업자등록번호 (D-bp-entity-5, hive mirror)
  ├─ fiscalYearStartMonth  1-12 (KR default 1)
  ├─ countryCode       ISO 3166-1 alpha-2 (KR default)
  └─ workspaces[]      1:N

Member (인간/에이전트 통합)
  ├─ User    (email, aadObjectId, role: admin|member,
  │          entityScopes: JSON ["axev","axec"] — D-bp-entity-8 raw 순서,
  │          defaultEntity: 사용자별 명시적 default — D-bp-entity-7,
  │          ownedBy: 본인 owner FK (AI agent ↔ human, D-bp-entity-9))
  │     └─ EntityRole[]  per-(user, entity) 권한 grant (D-bp-entity-17,
  │                      role: admin|owner|member, grantedById audit)
  └─ Agent   (agentId by SDK, memberId)

Workspace (PARA row — Project/Area/Resource/Archive)
  ├─ entityId          → Entity (NOT NULL — D-bp-entity-3, General bucket 폐기)
  ├─ paraLayer         ParaLayer enum {PROJECT, AREA, RESOURCE, ARCHIVE}
  ├─ sourceWorkspaceId / sourceArtifactPath / copiedAt  (D-bp-entity-2 fork provenance)
  └─ Session (mode: chat|terminal|hybrid|agent)
       ├─ Messages
       └─ TerminalSessions

Issue
  ├─ assignee:Member
  └─ reporter:Member

AppSetting (key-value, frame_mcp_token 회전 저장)

OperatorAlert (D-bp-alert-1, L2 silent-drop 영구 기록)
  ├─ source            처리 진입점 (e.g. processChatBundle)
  ├─ chatId / messageId
  ├─ primaryError / ackError
  ├─ createdAt
  └─ acknowledgedAt / acknowledgedBy   (NULL = unresolved)

MailSendLog (D-bp-mcp-mail-1, 2026-05-29 — send_mail append-only audit; migration 20260529120000_add_mail_send_log)
  ├─ callerEmail        인증된 caller (MCP token email claim)
  ├─ sentAs             실제 발신 mailbox (caller, 또는 send-as target)
  ├─ subject
  ├─ toCount / ccCount / bccCount / attachmentCount   (주소 자체는 미저장)
  ├─ status             "sent" | "dry_run" | "failed"
  ├─ messageId          Graph sendMail 202 no-body → null
  ├─ httpStatus / error
  └─ createdAt          index: createdAt / (callerEmail, createdAt)

McpSchema (D-bp-artifact-1, 2026-05-23 — schema discovery cache)
  ├─ service           ← 'frame' | 'hive' | 'magnet' | 'stream' | …
  ├─ schemaId          ← 'frame.balance@1.0' (@version suffix 포함)
  ├─ envelopeVersion   ← MCP /schemas envelope.version
  ├─ body              ← JSONB { description, produced_by, key_fields, … }
  ├─ fetchedAt         ← @updatedAt — 매 fetch 마다 갱신
  ├─ firstSeenAt       ← 첫 발견 시점
  └─ @@unique([service, schemaId])  새 version = 새 row, body 변경 = drift warn

OAuth* (D-axe-idp-1, 2026-06-03 — Blueprint = 플랫폼 OIDC Provider; src/lib/platform-oidc.ts)
  ├─ OAuthAuthCode      단발 authorization code (PKCE S256, 10분 TTL, atomic 소비)
  ├─ OAuthRefreshToken  회전 refresh token (sha256 hash 만 저장, revokedAt/rotatedTo)
  └─ OAuthClient        RFC 7591 DCR 동적 클라이언트 (loopback/downstream) — axe-cli 는 정적

Artifact (D-bp-artifact-1, 2026-05-23 — typed fact unit)
  ├─ id                ← cuid PK
  ├─ schemaId          ← 'frame.balance@1.0' or 'freeform.llm@auto' — McpSchema lookup
  ├─ workspaceId       ← Workspace FK (CASCADE delete)
  ├─ entityId          ← cached from Workspace.entityId
  ├─ paraLayer         ← cached from Workspace.paraLayer
  ├─ content           ← JSONB — typed payload per schemaId
  ├─ citations         ← JSONB array — 6-kind discriminated union
  │                       (onedrive / frame.* / hive.* / teams.message /
  │                        mail.thread / external.web)
  ├─ confidence        ← Decimal(3,2) 0.00 ~ 1.00 (default 1.0)
  ├─ auditTrail        ← JSONB array — append-only event log
  │                       (propose / confirm / reject / edit / dispatch /
  │                        archive / restore)
  ├─ parentArtifactId  ← self-FK — sub-Project chain + dispatch fork
  ├─ createdAt / updatedAt / archivedAt
  └─ index: workspaceId / (entityId, paraLayer) / schemaId /
            parentArtifactId / archivedAt

ArtifactLink (D-bp-artifact-6, 2026-05-23 — multi-parent reference)
  ├─ artifactId        ← Artifact FK (CASCADE delete)
  ├─ workspaceId       ← Workspace FK (CASCADE delete)
  ├─ linkType          ← 'reference' | 'sub_project' | 'cross_link'
  ├─ linkedAt          ← @default(now())
  ├─ linkedById        ← User FK (SetNull on user delete — preserve audit)
  └─ PK (artifactId, workspaceId)  같은 artifact 동일 workspace 중복 link 차단
```

### entity scope flow

`User.entityScopes` 는 sign-in 콜백 (`auth.ts` → `hydrateEntityScopesForUser`) 가 [customers.yaml](https://github.com/soohunkang/blueprint/blob/main/src/lib/entity-scopes.ts) `user_entity_map` + `default_entities_by_domain` 에서 hydrate. admin 이 customers.yaml 미명시 시 DB 의 모든 Entity slug fallback (lock-out 방지). 각 `/api/workspaces/*` 라우트가 `requireWorkspaceEntityAccess` 로 entity gate 검증 (D-bp-entity-3: nullable early-out 제거, 모든 workspace 명시적 entity).

`User.defaultEntity` (D-bp-entity-7): hydration 시 null + non-empty scopes → scopes[0] 자동 set. scope 외 default → scopes[0] fallback. TopNav 의 fallback chain (D-bp-entity-8) = URL `?entity=` → localStorage `axe:lastEntity` → `defaultEntity` → `entityScopes[0]` → "all". createWorkspace fallback = opts.entitySlug → drivePath segment → defaultEntity → entityScopes[0] → throw. 사용자 변경 endpoint = `PATCH /api/user/default-entity { slug }` (server-side `entityScopes.includes(slug)` 검증). D-bp-entity-8 정정: hydrate 가 raw `customers.yaml` 순서 보존 (이전 alphabetic sort 폐기) — yaml list 순서가 진정한 default SOT.

### EntityRole (D-bp-entity-17, 2026-05-22)

entityScopes 가 entity 가시성만 표현하는 한계 해결. `EntityRole { userId, entitySlug, role: admin|owner|member }` 가 entity 별 3-tier 권한 SSOT. hierarchy `admin > owner > member` 를 `src/lib/entity-roles.ts` 의 `hasEntityRole(userId, slug, min)` / `requireEntityRole(...)` 로 평가. tenant-wide `User.role='admin'` (Soohun/ai@) 은 모든 entity 에 implicit admin (row 없어도 bypass — admin fallback 정신 계승).

Hydration 정책: signIn 콜백이 `hydrateEntityRolesForUser` 호출 — entityScopes 에 새로 들어온 slug 마다 `member` 행 자동 생성, scope 에서 제거된 slug 의 행은 revoke. owner/admin overlay 는 오직 `/axe/admin/users` 에서 tenant admin 이 grant (PATCH `/api/users/[id]/entity-roles { entitySlug, role: admin|owner|member|none }`).

MCP scope 매핑 (내부 API `GET /api/internal/entity-roles?email=...`, HMAC bearer `BLUEPRINT_INTERNAL_API_KEY`): admin/owner → hive `[read,write,approve,admin]` · frame `[read,write,close,admin]`; member → `admin` 제외 동일 set; row 없음 → 빈 set (entity 비노출). hive/frame OIDC RP 측 연동은 PR 2/3 에서 별도 결정.

### owned account switching (D-bp-entity-9, 2026-05-22)

`User.ownedBy` — owner (human) → owned (AI agent identity, e.g. ai@/cfo@) 관계. 운영자 1 인이 본인 + agent 여러 identity 운영하는 패턴. switch flow:

1. owner 가 TopNav 의 **사용자 이름 (button)** 을 클릭 → popover 메뉴 펼침 (D-bp-entity-12)
2. owned account 선택 시 `POST /api/auth/switch-account { targetUserId }` → server-side `target.ownedBy === caller.userId` 검증
3. httpOnly cookie `axe:active-user-id` set + reload
4. session callback (`src/lib/auth.ts`): 매 read 마다 cookie 발견 시 ownership 재검증 → `session.user.*` (id/email/name/role/entityScopes/defaultEntity) 모두 target 로 override. JWT 의 authenticated identity 는 untouched (audit trail 보존)
5. revert = 메뉴 에서 self 선택 → 동일 endpoint (no-op, dropdown 즉시 close)

`/api/auth/owned-accounts` GET = dropdown options 채움 (caller + ownedUsers). `isImpersonating=true` 시 trigger button lime border + label color + tooltip. mid-session ownership revoke 시 자동 revert (매 session read 재검증). admin role 도 bypass 불가 — owned-only.

**D-bp-entity-12 (TopNav UX)**: 사용자 이름 자체가 dropdown trigger (M365/Gmail 패턴). 별도 `<select>` 폐기. 메뉴 안에 `+ 다른 계정으로 로그인` 옵션 → `signIn("azure-ad", ..., { prompt: "select_account" })` 으로 Microsoft 계정 picker 호출. click-outside / Escape close + chevron rotate 120ms. data-testid `nav-user-name` 유지 (E2E backward compat).

### Artifact + PARA 지식 레이어 (📐 설계, M6)

Blueprint 가 PARA OS 로 동작하기 위한 typed fact layer. ctx skill 의 markdown PKM 진화형. 상세 = [아키텍처 페이지](/architecture/artifacts).

요지:
- **Artifact** = per-field typed fact (Postgres + JSONB). `schema_id` / `content` / `citations` / `confidence` / `paraLayer` / `entityId` / `audit_trail` / `parent_artifact_id`
- **Citation** = OneDrive `driveItemId+version` / frame query (entity+account+period+queried_at) / hive `employee_id` / Teams `chatId+msgId` / mail `threadId+msgId` / external `URL+fetched_at`. data 중복 0
- **PARA dispatch** = Project artifact 종료 시 Area/Resource/Archive 로 field-level routing. [M3 dispatch](/ops/roadmap#m3--blueprint-para-dispatch-d-bp-entity-1-pr-5) (workspace-level) 의 진화
- **ctx 진화** = curation interface (markdown 은 보조). 흐름: agent propose → ctx review → confirmed fact + audit

근거: [D-bp-artifact-1~5](/ops/decisions). 마일스톤 [M6](/ops/roadmap#m6--blueprint-artifact--para-지식-레이어).

### Schema discovery — frame/hive `/schemas` mirror (2026-05-23, B-bp-artifact-schema-discovery)

M6 의 첫 implementation slice. Blueprint 가 각 MCP 서비스의 `GET /{service}/schemas` envelope 을 fetch + cache + version. SoT 는 MCP 측 (`src/{frame,hive}/mcp/schemas.py`); Blueprint 는 discovery / mirror 만.

- **Prisma 모델**: `McpSchema { service, schemaId, envelopeVersion, body, fetchedAt, firstSeenAt }`. `@@unique([service, schemaId])`. body 변경 시 `fetchedAt` 갱신 + drift warning. 신규 schema_id (예: `frame.balance@2.0`) = 새 row
- **모듈**: `src/lib/artifact/schemas/` (services / fetcher / registry / types). MCP_SERVICES 에 frame + hive 등재 (확장 시 entry 추가만)
- **Admin API**:
  - `GET  /api/admin/mcp-schemas` — 전체 list + count_by_service + registered_services
  - `POST /api/admin/mcp-schemas/refresh` — fetch trigger (per_service `added/changed/unchanged/total` 반환)
- **CLI**: `npx tsx scripts/fetch-mcp-schemas.ts [--service frame]` (docker exec / launchd cron 모두 사용 가능)
- **Token**: 기존 `frame_mcp_token` AppSetting 재사용. hive 는 `hive_mcp_token` AppSetting 또는 `HIVE_MCP_TOKEN` env (현재 미배치, fetcher 가 auth_failed 로 cleanly skip)
- **에러 모델**: status ∈ `ok` / `auth_failed` / `network_error` / `parse_error` — drift 진단 surface 깨끗

다음 step: artifact 본체 Prisma migration ([`B-bp-artifact-prisma`](/ops/backlog)) + citation resolver ([`B-bp-artifact-citation-resolver`](/ops/backlog)).

### hive ↔ Blueprint entity universe SOT

| Source | role |
|---|---|
| `~/.axe/customers.yaml` `entities` | slug list (이 instance 에 활성화된 entity) |
| `hive.shared.entity` | **legal_name / biz_no / fiscal_year_start_month / country_code SOT** (`hive register-entity` CLI) |
| Blueprint `Entity` (이 페이지의 표) | hive metadata mirror + `kind` 분류. seed-entities.ts 가 매 boot upsert. `sys` 는 Blueprint 전용 (hive 미보유 — ad-hoc Teams 1:1 host) |

`/api/entities` GET = 클라이언트 entity 카탈로그 (`entityScopes` 권한 검증, TopNav fetch). hive 새 entity 등록 → Blueprint `seed-entities.ts` 갱신 → 매 boot upsert → 다음 page load 시 클라이언트 자동 인식 (재배포 0).

## Blueprint MCP 서버 (D-bp-mcp-1, 2026-05-21)

Blueprint **자신**의 read-only MCP 서버. Claude 웹 / 데스크탑 / Code 가 Custom Connector 로 등록 가능.

| 항목 | 값 |
|---|---|
| URL | `https://axe.axelabs.ai/blueprint/mcp` |
| Client ID | `482598f7-540c-462c-9dfd-b957651eb804` |
| Application ID URI | `https://axe.axelabs.ai/blueprint/mcp` |
| Scope | `https://axe.axelabs.ai/blueprint/mcp/mcp.access` |
| Tenant | `122fb574-7efa-476a-95b6-bee81bce2cce` (axellc.com) |
| Stack | Python 3.12 + FastMCP + Starlette + uvicorn (frame/hive 패턴 미러) |

### 22 tools (13 read + 7 calendar + 1 mail write + 1 teams write)

| Tool | 분류 | 설명 |
|---|---|---|
| `whoami` | read | 호출 사용자 (Caller) 정보 |
| `list_workspaces` | read | 본인 멤버십 워크스페이스 |
| `get_workspace` | read | 워크스페이스 상세 |
| `list_sessions` | read | 본인 세션 목록 |
| `list_issues` | read | 이슈 목록 (필터 가능) |
| `get_issue` | read | 이슈 상세 |
| `list_agents` | read | 등록된 에이전트 |
| `list_skills` | read | 등록된 스킬 |
| `search_people` | read | 멤버 검색 (이름/이메일) |
| `get_teams_message` | read | Teams `teams.microsoft.com/l/message/...` deep link → 본문·발신자·타임스탬프 + 첨부·인용·인라인이미지 ID (D-bp-mcp-teams-1, D-bp-mcp-teams-2) |
| `get_teams_hosted_content` | read | Teams 인라인 이미지 (hostedContents) → MCP image content block (Claude vision 직접 활용) (D-bp-mcp-teams-2) |
| `get_teams_attachment_file` | read | Teams `reference` 첨부 (OneDrive/SharePoint 공유 파일) `contentUrl` → text 또는 base64. 50 MB 기본 / 100 MB hard cap (D-bp-mcp-teams-3) |
| `send_teams_message` | **write** | Teams 채팅에 **AXE 봇(ai@axellc.com) 신원**으로 발송. chat_id/text/content_type/prefix/dry_run. **admin 전용** (봇 신원 발신 = broadcast-class). 봇이 멤버인 chat 만 (Graph invariant). 발신 시 humanizer + audit footer 자동. 경로 = `/api/teams/post` (CRON_SECRET) → `sendChatReply` (D-bp-mcp-teams-send-1) |
| `list_teams_chats` | read | 최근 Teams chat 목록 (`TeamsInboundMessage` 로그) — `send_teams_message` 용 chat_id 탐색. query(preview·sender ILIKE)/limit. chat당 최근 sender·preview·receivedAt. admin 전용 (D-bp-mcp-teams-send-1) |
| `create_event` | **write** | 호출자 본인 M365 캘린더에 이벤트 생성. subject/start/end/location/body/recurrence/attendees (외부 도메인 OK) (D-bp-mcp-calendar-1) |
| `update_event` | **write** | 기존 이벤트 부분 수정 (eventId + 변경 필드) |
| `delete_event` | **write** | 이벤트 삭제 + attendees 에게 cancellation 자동 발송 |
| `get_event` | read | 단일 이벤트 fetch by id |
| `list_events` | read | 시간 윈도우 내 이벤트 목록 (UTC 정렬) |
| `add_attendees` | **write** | 기존 attendees 보존 + 신규 추가 (case-insensitive dedup), Graph 가 초대 자동 발송 |
| `find_free_time` | read | 다수 attendee 의 free/busy view (Graph `/me/calendar/getSchedule`) |
| `send_mail` | **write** | caller 본인 mailbox 에서 임의 수신자(외부 도메인 OK)로 이메일 발송. to/subject/body(html\|text)/cc/bcc/attachments(base64)/save_to_sent_items/dry_run + admin `as_user_email` send-as (D-bp-mcp-mail-1) |

> **Write tools (calendar) — 기본 동작**: caller 본인 M365 캘린더에 적용. `getClientForUser(callerUserId)` 로 caller 의 delegated token (`Calendars.ReadWrite`) 사용. 외부 도메인 attendee (gmail/naver/personal) = Graph 가 SMTP 초대 메일 처리, 별도 provider 통합 불필요.
>
> **Admin send-as (D-bp-mcp-calendar-2)**: `create_event`/`update_event`/`delete_event`/`get_event`/`list_events`/`add_attendees` 6개 tool 에 `as_user_email` optional 파라미터. caller 의 Blueprint `role === "admin"` 인 경우만 작동 — target user 의 캘린더에 write, event organizer = target user 로 표시. 구현 = app-only Graph token (`getAppOnlyClient()` via client credentials) + `/users/{targetUserId}/events` endpoint. Azure App **Application permission** `Calendars.ReadWrite` + tenant admin consent 필요. 비-admin caller 가 `as_user_email` 사용 시 403 `send_as_forbidden`.
>
> **사용자 첫 호출 전 1회 re-consent 필요**: `Calendars.ReadWrite` scope 가 2026-05-26 추가됨. 기존 사용자가 cached token 으로 calendar tool 호출 시 graph_token_unavailable / 403 응답 → Blueprint web 에 sign out + sign in 으로 incremental consent. admin 이 tenant-wide consent 시 skip.
>
> Read-only Blueprint domain tools (whoami, list_*, get_*, search_people) 은 D-bp-mcp-1 의 "MCP 는 조회 surface, Blueprint web UI 가 issue/session 작성 SoT" 원칙 유지. Calendar 는 별개 도메인 (Microsoft Graph) 이므로 write 채택 — D-bp-mcp-1 의 SoT 와 무관.
>
> **Mail (`send_mail`, D-bp-mcp-mail-1)**: 기본 동작 = caller 본인 mailbox 발송 (`getClientForUser` delegated token; `Mail.Send` delegated 는 이미 `graph.ts` SCOPES 에 존재 → calendar 와 달리 **re-consent 불필요**). Graph `POST /me/sendMail`, body = `{ message: { subject, body, toRecipients, ccRecipients?, bccRecipients?, attachments? }, saveToSentItems }`. 외부 도메인 수신자 = Graph 가 SMTP 처리. **Admin send-as**: `as_user_email` 지정 시 caller 의 Blueprint `role === "admin"` 인 경우만 작동 — app-only token (`getAppOnlyClient()`) + `/users/{targetUpn}/sendMail`, From = target user. Azure App **Application permission** `Mail.Send` + tenant admin consent 필요 (Blueprint Next.js app `2b222356-...`, MCP connector app `482598f7-...` 아님). 비-admin caller 가 `as_user_email` 사용 시 403 `send_as_forbidden`. `dry_run=true` = 메시지 조립·검증·preview 만 반환 (미발송). Graph `sendMail` 은 202 no-body → `message_id` null (Sent 폴더 사본이 durable record). **모든 발송 (`sent`/`dry_run`/`failed`) = append-only `MailSendLog` 1 row** (caller · sentAs · to/cc/bcc count · subject · status · httpStatus) — injection-safety 정책 audit. 에러 코드: `send_as_forbidden` / `recipient_invalid` / `mail_send_not_consented` (Azure 권한 미동의 명시) / `graph_error{status, detail}`.
>
> **Teams tools**: `get_teams_message` 응답 schema: `{ id, chatId, sender, senderId, timestamp, lastEditedDateTime, messageType, bodyText, bodyHtml, bodyContentType, mentions[], attachments[], inlineImageIds[], quotes[] }`. `attachments` = OneDrive 파일 reference (`contentUrl`) / Adaptive Card 등. `quotes` = `messageReference` 자동 resolve (1-level only — quote-of-quote opaque). `inlineImageIds` = body HTML 에서 추출한 hostedContent ID 배열 — 각각 `get_teams_hosted_content(chatId, messageId, hostedContentId)` 로 fetch. `attachments[i].contentUrl` (contentType=reference) 은 `get_teams_attachment_file(contentUrl)` 로 본문까지 fetch.

> **Teams write (`send_teams_message` · `list_teams_chats`, D-bp-mcp-teams-send-1, 2026-06-04)**: Teams 발신을 1급 MCP 도구로 승격 (이전엔 운영자가 내부 API 역설계 + 수작업 DB 조회로만 가능). `send_teams_message` 는 **봇(ai@) 신원**으로 발송하므로 calendar/mail send-as 와 동일하게 **admin-gated** (`is_admin(callerUserId)` DB 재확인). 봇은 자기가 멤버인 chat 에만 post 가능 (Graph invariant). `list_teams_chats` 는 `TeamsInboundMessage` 로그에서 chat_id 를 찾아준다. **구현 = MCP-only** — 새 app route 대신 기존 `/api/teams/post` (CRON_SECRET, `env_file ../.env` 로 MCP 컨테이너에 이미 주입됨) 재사용 → blueprint **app 무배포** (P3005 마이그레이션 drift + main WIP 회피). **후속**: 일관성 위해 `/api/internal/teams/send` (`BLUEPRINT_INTERNAL_API_KEY`) + `TeamsSendLog` audit 로 이전 (app 청정 배포 가능해질 때 — backlog `B-bp-mcp-teams-send-internal-route`).
>
> 세 tool 모두 Blueprint Next.js 의 `POST /api/internal/teams/{fetch-message,fetch-hosted-content,fetch-attachment-file}` 를 docker 네트워크로 호출 (Bearer `BLUEPRINT_INTERNAL_API_KEY` 공유 비밀). Graph 호출은 bot identity (`ai@axellc.com`, app perm `ChatMessage.Read.All` + Sites/Files Read). bot 이 해당 chat 멤버여야 200. 미멤버 chat / share = 403. Channel 컨텍스트 URL 은 501 (follow-up). hostedContent 10 MB 초과 시 413. attachment file 50 MB 기본 (호출 시 `maxBytes` override 가능, 100 MB hard cap).

### 데이터 흐름

```
cloudflared (axe.axelabs.ai/blueprint/mcp)
  → axe-blueprint-mcp-proxy (Caddy, host:3151)
  → blueprint-mcp:3000 (docker network alias)
    → blueprint-mcp-blue:3000 (active) OR blueprint-mcp-green:3000 (passive)
      = Python+FastMCP+Starlette
    → JWTAuthMiddleware
      - Authorization: Bearer <token>
      - iss-dispatch (unverified peek → full RS256 verify):
          · iss == BLUEPRINT_ISSUER  → verify_blueprint_token  (auth_blueprint.py)
              플랫폼 토큰 (D-axe-idp-1, `axe login`). Blueprint JWKS,
              aud=https://axe.axelabs.ai. ← 자기 OP 의 resource server
          · else                     → verify_microsoft_access_token (auth_oidc.py)
              Microsoft Entra access_token (claude.ai Custom Connector).
              JWKS RS256 + multi-aud (client_id GUID OR app_id_uri)
      - email claim → "User" 테이블 lookup (Prisma schema raw SQL) — 양 경로 공통
    → Caller ContextVar 주입
    → FastMCP streamable_http_app → tool handler
```

### 운영

- 컨테이너: `blueprint-mcp-blue` (host port 3152, compose service `mcp-blue`, alias `blueprint-mcp` active) + `blueprint-mcp-green` (host port 3153, compose service `mcp-green`, passive). frame `frame-mcp-blue/green` 패턴 1:1 미러.
- Proxy: `axe-blueprint-mcp-proxy` (host port 3151) → docker alias `blueprint-mcp` → active color.
- DB 직결: `blueprint-postgres` 의 `User`/`Workspace`/`Session`/`Issue`/`Agent`/`Skill` 테이블 read-only
- 헬스체크: `curl http://127.0.0.1:3152/health` (blue) · `curl http://127.0.0.1:3153/health` (green) · `curl http://127.0.0.1:3151/health` (Caddy → 현재 active)
- Swap (zero-downtime): `docker network disconnect blueprint_default blueprint-mcp-blue && docker network connect --alias blueprint-mcp blueprint_default blueprint-mcp-green && docker exec axe-blueprint-mcp-proxy caddy reload --config /etc/caddy/Caddyfile`. cloudflared 비건드림.

### Custom Connector 등록 절차

운영자: `axe secret send AZURE_BLUEPRINT_MCP_CLIENT_SECRET --service blueprint --to <local-part>` → URL 발급 → [`/api/admin/broadcast-dm`](#admin-teams-api--apiadminbroadcast-dm) 으로 수신자 Teams DM 자동 발사. 전체 흐름은 [/architecture/secrets § 사람에게 전달](/architecture/secrets#사람에게-전달--bitwarden-send), 수신자 화면 절차 + 함정은 [/architecture/mcp-server-checklist § 7](../architecture/mcp-server-checklist) + [/onboard/claude-frame-setup](/onboard/claude-frame-setup) (동일 절차, MCP URL 만 다름).

### 개발 history

frame/hive 와 패턴 동일하지만 byte-by-byte 미러 실패로 **5층 양파껍질 사고** (PR #331 ~ #335 누적). 회고는 [/architecture/mcp-server-checklist § 10](../architecture/mcp-server-checklist#10-양파껍질-회고-blueprint-mcp-2026-05-21).

## Teams bot integration

Microsoft Teams 1:1 / group chat 에서 `@AXE AI` (또는 `@CFO`) 멘션 시 봇이 Claude Code 세션을 띄워 응답하는 surface. Web UI 의 chat panel 은 2026-04-25 폐기됨 ([architecture-chat-panel-removed](memory)) — Teams 가 사용자-facing primary, Web UI 는 admin/observe 전용.

### Agent identity

| Agent | UPN | 용도 |
|---|---|---|
| **ai** | `ai@axellc.com` | 일반 워크스페이스 dispatcher. 모든 chat 에 기본 멤버. `getBotGraphClient()` 가 가리키는 레거시 single-bot identity. |
| **cfo** | `cfo@axellc.com` | 회계 도메인 (frame MCP 보유). axe CFO 1:1 chat 의 전용 멤버. |

각 agent 는 Microsoft Graph user 계정 1 개 + Blueprint `Agent` row 1 개. `getClientForUser(agentUserId)` 가 agent 의 delegated token 으로 Graph 인증 — agent 가 chat 의 member 일 때만 인증 성공 (multi-bot 1:1 chat 의 자연 invariant). `getBotGraphClient()` 는 ai@ 의 shared client 로 레거시 경로 (ai@ 가 chat 멤버일 때만 작동).

### Inbound 처리 흐름

1. Microsoft Graph change-notification → `/api/teams/webhook` → `TeamsInboundMessage` 인큐
2. processor: `messageType === "message"` filter (systemEvent / typing 제외) → 봇 mention 검증 → trigger agent 해결 (mentioned identity)
3. agent ↔ chat 매핑 검증 (`resolveChatMemberAgentUserId`) → workspace ACL → 첨부 처리 → claude-invoker
4. reply: `sendChatReply` 또는 정정 시 `editChatMessage` (PATCH in-place edit)
5. resolve: `TeamsInboundMessage.resolvedAt` + `resolution` 마킹

### 첨부 처리 (`src/lib/teams/attachments.ts`)

`processTriggerAttachments` 가 trigger message 의 `attachments[]` 를 contentType 별 분기:

| `contentType` | 처리 | 결과 위치 |
|---|---|---|
| `reference` (OneDrive/SharePoint 파일) | `downloadReferenceFile` agent token 으로 download. sha256 dedup → 워크스페이스 기존 파일 일치 시 inbox 복사본 제거 후 기존 path 가리킴 | `_teams_inbox/<YYYY-MM-DD>/<filename>` |
| inline image (`<img src="hostedContents/.../$value">`) | `downloadHostedContent` + content-type sniff → 확장자 추론 | `_teams_inbox/<date>/image_<msgIdShort>_<idx>.<ext>` |
| `messageReference` (Teams quote/reply) | `getChatMessage(chatId, refMsgId, agentId)` 로 quoted body fetch → HTML → plain text → placeholder 상단에 `[인용 메시지]` block 으로 inline | LLM prompt only (disk 저장 X). fetch 실패 시 `attachment.content.messagePreview` 200자 fallback |
| 기타 (card / file 등) | `[unsupported contentType: X]` placeholder (LLM 이 "못 봤다" 답신 위험 — 향후 audit 대상, [known-gaps](/ops/known-gaps)) | — |

multi-bot chat 의 SharePoint URL 은 chat 참여자에게만 read 권한이 grant 되므로 — agent token propagation 이 필수. 레거시 `getBotGraphClient` 만 쓰면 비참여 chat 에서 403/404 발생 ([feedback_multi_bot_attachment_download_gap](memory) 2026-05-13 fix).

### Reply 안전 정책

- **`sanitizeBotReply`**: markdown + HTML defense-in-depth. system-reminder ban, table → 바이너리 only, dangling cross-reference strip 등 4 종 룰 ([architecture_bot_reply_pipeline](memory) PR #157 + PR #165).
- **silent-drop defense-in-depth**: 사용자 @mention 에 무응답 금지 — 5 종 silent-drop class 별 layer 차단 ([feedback_silent_drop_defense_in_depth](memory) PR #261/#263/#266).
- **In-place edit**: 잘못된 reply 정정 시 softDelete + repost X → `editChatMessage` PATCH. 수정됨 표시 1개만 ([feedback_in_place_edit_over_repost](memory)).
- **System prompt** vs **sanitizer**: 시스템 프롬프트 텍스트 룰이 반복 위반될 경우 sanitizer 에 deterministic regex enforcer 추가 (layer 2 fail-safe).

### 알려진 제약 / 후속

- **Context window messageReference**: trigger message 만 quote 처리. `TeamsContextMessage` (rolling context) 의 quote 는 미해석 — [B-bp-teams-context-msgref](/ops/backlog) 후속.
- **Unhandled contentType audit**: card / file / 기타 — [known-gaps](/ops/known-gaps).
- **`get_session` + `list_messages` MCP tool 부재**: agent transcript 외부 비공개 — Stage 1 전 추가 예정.

### Admin Teams API — `/api/admin/broadcast-dm`

운영자 broadcast / 직원 onboarding 흐름에서 bot identity (ai@axellc.com) 가 1:1 Teams DM 직접 발사. 봇이 @mention 받지 않는 **proactive outreach** 경로.

| 필드 | 값 |
|---|---|
| **POST** | `/api/admin/broadcast-dm` (`:3100` 내부, `blueprint.axellc.com` 외부) |
| **Auth** | `Authorization: Bearer $CRON_SECRET` (NextAuth 세션 불필요 — admin-only) |
| **Body** | `\{ emails: string[], text: string, contentType?: "text" \| "html" \}` |
| **흐름** | each email → `User.aadObjectId` lookup → `POST /chats` (oneOnOne, Graph idempotent — 같은 pair 면 같은 chat id) → `POST /chats/\{id\}/messages` |
| **응답** | `\{ summary: \{total, sent, skipped, error\}, results: [\{email, status, chatId?, messageId?, reason?\}] \}` |

소스: `src/app/api/admin/broadcast-dm/route.ts`. 패턴 derivation: `src/lib/agents/cron-failure-alert.ts:postAlertToAdmin`. 운영자 onboarding 패턴은 [/architecture/secrets § 자동 발사](/architecture/secrets#자동-발사--apiadminbroadcast-dm) 참조.

**Agent context** reply (사용자 @mention 후 봇 응답) 은 본 엔드포인트 X — `mcp__blueprint-graph__graph_chat_message_post` MCP tool 사용.

## MCP 클라이언트 (frame backend 호출용)

`src/lib/mcp/frame.ts`:

```typescript
const frameServerUrl = process.env.FRAME_MCP_URL || 'http://frame-mcp:3710/mcp'
const token = await getAppSetting('frame_mcp_token') ?? process.env.FRAME_MCP_TOKEN
// HTTP MCP Streamable transport with Bearer
```

`frame-mcp` 는 docker network `artemis_default` 의 alias (frame-mcp-blue 또는 green 으로 swap).

토큰 해결 순서:
1. AppSetting `frame_mcp_token` (DB 저장, cron job 으로 회전)
2. env `FRAME_MCP_TOKEN` (백업)

## customers.yaml 사용

`src/lib/customers.ts`:

```typescript
export function getCustomerByPublicDomain(host: string): Customer | undefined {
  // host: "axe.axelabs.ai" → axe customer
}

export function userBelongsToCustomer(email: string, customerId: string): boolean {
  // email "ai@axellc.com" → axellc.com → axe
}
```

마운트: 호스트 `/Users/axe/.axe/customers.yaml` → 컨테이너 `/etc/axe/customers.yaml:ro`. mtime 변경 시 자동 재로드.

## docker-compose

```yaml
services:
  app:
    build: . (Dockerfile)
    container_name: blueprint-app
    ports:
      - "3100:3000"   # HTTP
      - "3101:3001"   # WebSocket
      - "3102:7681"   # ttyd (선택)
    volumes:
      - app-data:/app/data
      - claude-data:/home/nextjs/.claude         # skill sync
      - /Users/axe/.axe/customers.yaml:/etc/axe/customers.yaml:ro
    env_file: ../.env
    networks: [artemis_default]
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
```

## 환경 변수 (핵심)

```bash
# Auth
NEXTAUTH_URL=https://axe.axelabs.ai
NEXTAUTH_SECRET=                     # ≥32자
AZURE_AD_CLIENT_ID=                  # Microsoft Entra 'Blueprint' app
AZURE_AD_CLIENT_SECRET=
AZURE_AD_TENANT_ID=                  # customers.yaml 의 axe.sso.tenant_id

# Blueprint MCP — 플랫폼 토큰 (D-axe-idp-1) resource-server 신뢰.
# Blueprint MCP 가 자기 OP 가 발행한 `axe login` 토큰을 받게 한다 (미설정 시
# Microsoft Entra 경로만 → 자기 플랫폼 토큰을 unknown_kid 로 401, 2026-06-04 fix).
# JWKS 는 issuer 에서 파생({issuer}/.well-known/jwks.json). 주권 테넌트는 자기 URL 로.
BLUEPRINT_ISSUER=https://blueprint.axellc.com   # = OP issuer (NEXTAUTH_URL)
BLUEPRINT_AUDIENCE=https://axe.axelabs.ai       # 플랫폼 단일 audience

# Database — D-config-17 cutover 후 (2026-05-15~) Postgres.
# ⚠️ D-bp-mcp-3 (2026-05-22): `.env` 에 DATABASE_URL 명시 X 권고 — compose 의
# `${DATABASE_URL:-postgresql://...}` default 가 모든 service (app + mcp blue/green)
# 에 일관 적용. .env 에 SQLite legacy `file:./...` 잔존 시 blueprint-mcp 가
# SQLAlchemy parse fail 로 모든 OAuth 요청 silent 500 (8h 종일 broken incident).
# mcp/config.py 가 postgresql:// → postgresql+asyncpg:// 자동 변환.
DATABASE_URL=postgresql://blueprint:${BLUEPRINT_DB_PASSWORD}@postgres:5432/blueprint
BLUEPRINT_DB_USER=blueprint              # docker-compose default
BLUEPRINT_DB_PASSWORD=                   # vault-pulled (D-ops-17)
BLUEPRINT_DB_NAME=blueprint

# Multi-tenant
CUSTOMERS_YAML_PATH=/etc/axe/customers.yaml

# MCP
FRAME_MCP_URL=http://frame-mcp:3710/mcp
FRAME_MCP_TOKEN=                     # AppSetting fallback

# Claude
CLAUDE_CODE_OAUTH_TOKEN=             # 또는 ANTHROPIC_API_KEY
```

## Admin vs Settings 경계

```
/axe/admin/*    ← 조직 수준 (멤버 관리, 권한 부여, 감사 로그)
/axe/settings   ← 개인 수준 (개인 토큰, 알림 환경설정)
```

Admin 라우트는 `User.role = 'admin'` 만 접근.

## 디자인 시스템 (@axe/ui, D-bp-ui-1, 2026-05-22)

`/Users/axe/axelabs/src/lib/{tokens,styles}/` SSOT 의 디자인 토큰 + 컴포넌트 스타일을 Blueprint 의 `src/app/_axe-ui/` 로 sync. axelabs 메인 앱 · docs.axelabs.ai 와 동일한 토큰 (claret/cream/neon/noir 12-step) 사용.

| 동작 | 명령 |
|---|---|
| SSOT 가져오기 | `npm run sync-axe-ui` (axelabs HEAD + src/lib/ 마지막 변경 SHA 를 VERSION 에 기록) |
| 변경 감지 (dry-run) | `npm run check-axe-ui` — sync 후 stat 보여주고 revert |
| 자동 sync | `predev` / `prebuild` hook 이 매 `npm run dev`/`build` 마다 실행. Docker 빌드 안에서는 axelabs 경로 부재 → graceful skip (committed 산출물 사용). |

### Blueprint 전용 패치 2종 (`scripts/sync-axe-ui.mjs`)

1. **목적지 경로** — canonical (axelabs-docs) 는 `app/_axe-ui/`. Blueprint 는 `src/` source root 라 `src/app/_axe-ui/`.
2. **`fonts.css` 의 `@import url(...)` strip** — Tailwind 4 + Turbopack 이 토큰 파일들을 globals.css 로 inline 한 뒤 fonts.css 의 `@import url(pretendard/d2coding)` 이 다른 rule 뒤로 밀려 CSS spec 위반으로 **브라우저가 silent drop**. sync 단계에서 strip → Pretendard/D2Coding 은 `layout.tsx` 의 `<link rel="stylesheet">` 로 로드 (SSOT 가 Clash Display 에 대해 이미 인정한 회피 패턴의 확장).

### Legacy var alias bridge (`src/app/globals.css`)

35+ tsx 가 `var(--navy)` · `var(--white)` · `var(--gray-ax)` · `var(--dark-border)` 등 기존 토큰을 직접 사용. 비파괴 채택을 위해 globals.css 에서 alias:

```css
--navy: var(--bg-base);          /* light: #f5f1e8 / dark: #1a0610 */
--white: var(--text-primary);    /* light: #1f1a13 / dark: #f2e8ee */
--gray: var(--text-muted);
--dark-border: var(--border-subtle);
/* ...등 */
```

`data-theme="dark"` 토글 시 기존 tsx 무수정으로 자동 전환. 향후 별도 PR 로 inline ref 를 새 토큰명 (`var(--bg-base)` 등) 으로 점진 마이그레이션.

### 테마 토글

`<html lang="en" data-theme="dark">` 기본. 라이트 모드 전환은 `document.documentElement.setAttribute("data-theme", "light")` — 토글 UI 는 별도 후속. SSOT colors.css 가 `[data-theme="…"]` + `.light/.dark` 양쪽 지원 (next-themes 호환 — `[architecture/decisions D-axe-ui-1](/ops/decisions)` 참조).

## 외부 의존성

| 시스템 | 통합 지점 |
|---|---|
| Microsoft Entra ID | NextAuth provider + Graph API |
| Microsoft Graph | Mail, Chat, Files, Teams 권한 |
| frame MCP | HTTP MCP (회계 도구) |
| Claude API | Anthropic SDK (에이전트 실행) |
| Cloudflare Tunnel | external 노출 |
| jsDelivr CDN | Pretendard + D2Coding 폰트 (CSP `style-src` + `font-src` 허용) |
| Fontshare CDN | Clash Display 폰트 (디스플레이 헤딩 옵션) |

## 운영 노트

- **세션 maxAge**: 365일 (NextAuth)
- **Cookie**: `__Secure-next-auth.session-token` (prod)
- **Skill sync**: `claude-data:/home/nextjs/.claude` 볼륨, git-based SOT
- **PTY**: max buffer 2000 lines per session
- **CORS**: WebSocket origin allowlist (`src/server/websocket.ts` L180-183)

## 관련 문서

- [Frame service](/services/frame) — 회계 MCP backend
- [Microsoft Entra ID app registration](/partner/registration/entra-app)
- [/Users/axe/blueprint/docs/project-blueprint.md](https://github.com/soohunkang/blueprint/blob/main/docs/project-blueprint.md) — 마스터 문서 (47KB)
