Akan.js
Docs
문서컨벤션레퍼런스Cheatsheet
Akan.js
문서컨벤션레퍼런스Cheatsheet
Akan.js

MIT 라이선스 하에 배포되었습니다.

Akan.js 공식 컨설팅 서비스AkansoftCopyright © 2026 Akan.js 모든 권리 보유.시스템 관리자bassman
사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
일반▾
인증과 권한에이전트를 위한 OAuth스키마 설계텍스트 검색엣지 컴퓨팅파일 관리Single Sign-OnDataList & Enum
인터페이스▾
CRUDEndpointMCP 서버에이전트 채팅Form
관측성▾
로깅의존성 주입에러 처리메트릭
성능▾
캐싱이미지 최적화지연 로딩쿼리변경큐실시간
모바일▾
설정Push NotificationsDeep LinksUI & Keyboard데스크톱 배포
개발▾
문서화스키마 문서스크립트콘솔도커쿠버네티스PWA테스트
사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
일반▾
인증과 권한에이전트를 위한 OAuth스키마 설계텍스트 검색엣지 컴퓨팅파일 관리Single Sign-OnDataList & Enum
인터페이스▾
CRUDEndpointMCP 서버에이전트 채팅Form
관측성▾
로깅의존성 주입에러 처리메트릭
성능▾
캐싱이미지 최적화지연 로딩쿼리변경큐실시간
모바일▾
설정Push NotificationsDeep LinksUI & Keyboard데스크톱 배포
개발▾
문서화스키마 문서스크립트콘솔도커쿠버네티스PWA테스트
이전Endpoint다음에이전트 채팅

MCP 서버

이미 작성한 시그널이 POST /mcp에서 AI 에이전트에게 그대로 제공됩니다. 별도의 API도, 시그널 파일에 더 적을 것도 없이 같은 엔드포인트가 같은 가드, 미들웨어, 서비스를 거칩니다. 내 페이지 안의 채팅은 다른 표면인 인페이지 에이전트입니다.
이 페이지에서 쓰는 말
용어설명
MCP
Model Context Protocol입니다. Claude Code, Cursor 같은 AI 클라이언트가 서버를 호출할 때 쓰는 표준입니다.
tool
모델이 호출할 수 있는 함수입니다. 게시된 엔드포인트 하나가 툴 하나가 되며, 이름은 엔드포인트 키입니다.
resource
akan:// URI로 가리키는 조회입니다. 클라이언트가 컨텍스트로 붙일 수 있습니다.
prompt
사용자가 MCP 클라이언트에서 슬래시 커맨드로 실행하도록 게시한 화면입니다.
catalogue
클라이언트가 접속할 때 내려받는 툴, 리소스, 프롬프트 목록입니다.
무엇이 무엇으로 게시되나
작성한 것
tool
resource
prompt
시그널 (*.signal.ts)
query · mutation
✓
커스텀 엔드포인트와 생성된 create, update, remove는 키 이름 그대로 툴이 됩니다.
<model> · <model>List…
✓
✓
생성된 조회는 툴이면서 akan:// 리소스 URI도 받습니다.
<model>Insight…
✓
가리킬 대상이 없는 집계값이라 URI 없이 툴로만 남습니다.
pubsub · message
노출되지 않습니다. 인자가 MCP 요청에는 없는 소켓을 읽기 때문입니다.
페이지 (page/**)
page().prompt()
✓
사용자가 슬래시 커맨드로 실행하는 화면입니다. 모델이 고르는 것이 아닙니다.
✓이렇게 게시됨해당 없음
엔드포인트가 거부되는 경우
노출은 가드를 따르며, 엔드포인트별 opt-in은 없습니다. 아래 조건을 위에서부터 확인해 하나도 해당하지 않으면 게시됩니다:
거부 조건
↳ 이유와 대처
mcp: false를 선언했다
일부러 선반에서 뺀 것입니다. 가드와 HTTP는 그대로입니다.
Person처럼 static agents = false인 가드가 붙어 있다
사람만 할 수 있는 행위라 어떤 모델에게도 내밀지 않습니다.
guards를 적지 않았거나 빈 배열이다
누가 호출할지 정한 적이 없습니다. 익명 호출이 의도라면 guards: [Public]을 적으세요.
생성된 light<Model> 조회다
<model>과 같은 도큐먼트를 작은 형태로 읽을 뿐이라, 에이전트는 대신 <model>을 호출합니다.
pubsub이나 message다
웹소켓으로 동작하고, 인자가 MCP 요청에는 없는 소켓을 읽습니다.
읽기 전용 배포인데 query가 아니다
readOnly 밸브는 가드가 무엇을 허용하든 모든 mutation을 뺍니다.
Any, Upload, Binary를 반환한다
무엇이 돌아오는지 모델에게 설명할 수 없고, 원시 바이트는 컨텍스트 창만 채웁니다.
파일 업로드를 받는다
파일 업로드는 MCP로 표현할 방법이 없습니다.
가드가 Public 하나뿐인 mutation이다
쓰기에 [Public]만 붙인 것은 가드가 없다는 말을 풀어 쓴 것입니다. 실제 가드를 붙이세요.
필수 인자의 타입이 Any다
Any는 스키마에서 빠지므로, 대신 이름 있는 filter 슬라이스를 노출하세요.
거부된 툴은 없는 툴과 똑같아 보입니다. 둘 다 Unknown tool로 답하고, 가드의 거절은 가드 이름 없이 항상 You are not permitted to perform this action.입니다. 두 메시지를 더 친절하게 만들지 마세요. 그 차이가 비공개 표면을 하나하나 알아내는 단서가 됩니다.

1. 서버 켜기

/mcp는 기본으로 마운트되므로 새 앱도 이미 제공하고 있습니다. 설정은 main.ts가 아니라 lib/option.ts에서 setMcp()로 바꿉니다:
apps/myapp/lib/option.ts
  • 마운트 순서. 모든 lib의 option.ts를 마운트 순서대로 읽고 앱의 것을 마지막에 읽으므로, 최종 결정은 앱이 합니다.
  • 코드가 env를 이깁니다. 끄는 스위치만 예외입니다. 코드에 쓴 값이 같은 이름의 AKAN_MCP_* env보다 우선하지만, undefined를 쓴다고 env 값이 지워지지는 않습니다. AKAN_MCP=false이면 코드와 상관없이 /mcp가 꺼집니다.
  • 끄는 방법. 코드에서는 setMcp(false), env에서는 AKAN_MCP=false입니다.
  • 함수 형태. setMcp((options) => ({ … }))는 env.server.*의 서버 옵션을 받으므로, 부팅 때 정해지는 값에 씁니다. libs/shared가 auth를 이렇게 만듭니다.
옵션
enabledboolean기본값 trueAKAN_MCPAKAN_PUBLIC_MCP
/mcp를 마운트할지 정합니다. env에 false나 0을 주면 코드와 상관없이 꺼집니다.
readOnlyboolean기본값 falseAKAN_MCP_READONLYAKAN_PUBLIC_MCP_READONLY
가드가 무엇을 허용하든 query만 게시합니다. env는 true나 1일 때만 켭니다.
pathstring기본값 /mcpAKAN_MCP_PATH
마운트 경로입니다. OAuth 리소스 식별자가 이 값을 따르므로, 토큰에 담을 aud도 함께 바뀝니다.
versionstring기본값 0.0.0AKAN_MCP_VERSION
serverInfo.version으로 보고됩니다. OpenAPI 문서와 같은 자리표시자입니다.
instructionsstring기본값 Domain tools for the <app> app.AKAN_MCP_INSTRUCTIONS
툴 목록과 함께 모델에 전달됩니다. 앱의 용도와 먼저 쓸 툴을 적습니다.
allowedOriginsstring[]기본값 []AKAN_MCP_ALLOWED_ORIGINS
DNS rebinding 검사를 통과시킬 추가 origin입니다. Origin은 브라우저에서 도는 클라이언트만 보냅니다.
pageSizenumber기본값 100AKAN_MCP_PAGE_SIZE
카탈로그 한 페이지의 항목 수입니다. 클라이언트는 nextCursor로 나머지를 받습니다.
languagestring기본값 enAKAN_MCP_LANGUAGE
카탈로그와 에러 문구에 쓰는 언어 하나이며, 서버 전체에 적용됩니다.
outputSchema"full" | "shallow" | "none"기본값 shallowAKAN_MCP_OUTPUT_SCHEMA
툴이 공개하는 결과 형태입니다. shallow는 중첩 모델의 이름만, full은 전부 인라인, none은 생략입니다.
legacyTextBlockboolean기본값 trueAKAN_MCP_LEGACY_TEXT
구조화된 결과를 text block에 JSON으로 한 번 더 싣습니다. env로는 끄기만 할 수 있습니다.
rateLimit{ calls?, windowMs?, concurrent? } | false기본값 120 calls / 60s, 8 in flightAKAN_MCP_RATE_LIMITAKAN_MCP_CONCURRENT
tools/call, resources/read, prompts/get에 대한 호출자별 예산이며, 프로세스마다 셉니다.
promptBudgetnumber기본값 60000AKAN_MCP_PROMPT_BUDGET
페이지 프롬프트 하나가 목록이 잘리기 전까지 붙일 수 있는 화면 데이터의 글자 수입니다.
auth{ authorizationServers?, scopes?, resource?, verify? }기본값 {}AKAN_MCP_AUTH_SERVERSAKAN_MCP_SCOPESAKAN_MCP_RESOURCE
OAuth 리소스 서버로서의 신원입니다. authorization server를 적으면 토큰이 필수가 됩니다.
  • 예산을 넘으면 Retry-After와 함께 429로 답합니다. 목록 조회는 세지 않고, 레플리카가 N개면 예산도 N개입니다.
  • outputSchema: "none"이면 text block은 유지됩니다. legacyTextBlock 값과 상관없습니다. 클라이언트는 선언된 스키마가 있을 때만 structuredContent를 읽기 때문입니다.

2. 엔드포인트 작성하기

가드만 적으면 끝입니다. 실제 가드가 붙은 커스텀 query나 mutation은 툴로 게시됩니다:
apps/myapp/lib/task/task.signal.ts
툴 구성설명
name
적은 그대로의 엔드포인트 키입니다. 예를 들면 startTask입니다.
inputSchema
.param(), .search(), .body() 인자를 한 객체로 모읍니다. .search() 인자는 선택입니다.
outputSchema
반환 모델입니다. 스칼라나 null이 될 수 있는 단일 반환은 텍스트로만 나갑니다.
titledescription
엔드포인트의 딕셔너리 라벨과 .desc()입니다.
annotations
query에는 readOnlyHint, remove…나 delete… mutation에는 destructiveHint가 붙습니다.
딕셔너리 항목도 같은 변경에서 함께 씁니다:
apps/myapp/lib/task/task.dictionary.ts
  • 에이전트는 설명을 보고 툴을 고릅니다. 그래서 .desc()가 없는 툴은 지저분한 게 아니라 고장 난 툴입니다.
  • 인자마다 설명을 적으세요. .arg()의 설명이 input schema에 그대로 실립니다.

3. 슬라이스와 CRUD

생성된 조회와 CRUD는 slice()의 guards 맵으로 게시됩니다. 이름 있는 슬라이스는 그 맵을 물려받지 않으므로, 자기 가드를 직접 적어야 게시됩니다.
생성되는 항목설명
taskListtaskInsight
루트 슬라이스입니다. guards.root로 막고, mcp: { root: false }로 뺍니다.
task
전체 조회입니다. guards.get과 mcp: { get: false }를 따르며, lightTask는 게시되지 않습니다.
createTaskupdateTaskremoveTask
guards.cru와 mcp: { cru: false }를 따르고, create 같은 동사별 키로 따로 정할 수도 있습니다.
taskListInTodotaskInsightInTodo
이름 있는 슬라이스입니다. 자기 init({ guards, mcp })만 봅니다.
항목을 선반에서 빼려면 mcp: false를 씁니다. slice()에서는 guards와 같은 키의 맵입니다:
apps/myapp/lib/task/task.signal.ts
  • mcp: false는 권한이 아니라 큐레이션입니다. 항목을 선반에서 뺄 뿐, 가드와 HTTP는 그대로입니다.
  • slice()의 맵은 guards와 키가 똑같습니다. root, get, cru, create, update, remove이고, 닿는 범위도 루트 슬라이스와 생성된 CRUD로 같습니다.
  • 그냥 mcp: false라고 쓰면 전부 꺼집니다. root, get, cru로 펼쳐지고, create, update, remove는 cru를 물려받습니다.
  • 이름 있는 슬라이스와 커스텀 엔드포인트는 자기 옵션에 boolean을 씁니다. 맵은 쓰지 않습니다.
리소스 URI
게시된 생성 조회에는 클라이언트가 바로 읽을 수 있는 URI도 붙습니다:
생성되는 리소스 URI
  • URI는 <model>과 <model>List… 조회에만 붙습니다. insight는 가리킬 대상이 없는 집계값이고, 커스텀 엔드포인트는 툴은 갖지만 템플릿은 받지 않습니다.
  • 루트 목록은 세 번째 경로 조각이 없는 …/list입니다. 그 자리는 슬라이스 키의 몫이기 때문입니다.
  • lightTask는 툴도 URI도 없습니다.
루트 목록은 filter 이름으로만 좁힙니다. queryKey는 모델의 filter 이름 하나를 받지만, args는 Any라 스키마에서 빠지고 값을 보내면 거부됩니다. 에이전트가 filter 인자까지 넘겨야 한다면 이름 있는 filter 슬라이스를 선언하세요.

4. 화면을 프롬프트로 게시하기

프롬프트는 엔드포인트가 아니라 화면입니다. 페이지에 .prompt(name, description)으로 선언하면, 사용자가 슬래시 커맨드로 실행하고 모델은 그 페이지가 불러온 데이터를 받습니다:
apps/myapp/page/project/[projectId]/tickets.tsx
  • description이 지시의 전부입니다. API 용어로, 영어로 씁니다. <Agent.Guide> 문구는 MCP에 전달되지 않습니다.
  • 인자는 페이지 선언에서 나옵니다. .param()은 필수, .search()는 선택이고, desc가 인자 설명이 됩니다.
  • 배열 인자는 쉼표로 구분해 입력합니다. 설명 끝에 Comma-separated list.가 붙고, ID, Int, enum 값은 페이지 자신의 선언으로 검증합니다.
  • 이름은 유일해야 합니다. ^[A-Za-z0-9_-]{1,64}$에 맞고 모든 페이지에서 겹치지 않아야 하며, prompts/list는 .prompt()가 있는 페이지를 모두 나열합니다.
  • 프롬프트는 페이지에서만 선언합니다. 시그널에는 prompt() 빌더가 없고, Msg는 공개 API가 아닙니다.
prompts/get이 돌려주는 것
prompts/get은 페이지 body(root layout, layout, 그다음 render)를 호출자의 bearer 토큰으로 RSC worker에서 실행합니다. 렌더링은 하지 않고 클라이언트 컴포넌트도 돌지 않습니다. 페이지가 보낸 fetch.* 조회 하나하나가 응답이 됩니다:
구성설명
user
페이지의 description이 첫 user 메시지로 들어갑니다.
resource
조회마다 하나씩이며, 엔드포인트의 반환 모델로 마스킹해 hidden, secret, visual 필드를 뺍니다.
uri
그 툴이 응답하는 akan:// URI입니다. 커스텀 조회는 akan://<toolKey>?args입니다.
Tools for this screen: …
조회한 모듈의 게시된 툴 중 호출자가 볼 수 있는 것이며, 이미 첨부한 조회는 뺍니다.
layout의 project와 page의 lightProject처럼 같은 도큐먼트를 두 모양으로 읽으면, 더 큰 쪽 하나만 첨부합니다.

프롬프트를 실행할 수 없을 때

프롬프트는 스스로 다시 실행할 수 없고 대신 쓸 컨텍스트도 없습니다. 그래서 화면이 거절하는 모든 경우는 호출자가 다음 행동을 고를 수 있는 메시지로 돌아옵니다. 아래 응답은 페이지 데이터 대신 나갑니다:
상황설명
필수 인자가 빠졌다
페이지를 실행하지 않고, 그 id를 찾을 수 있는 툴을 알려 줍니다.
인자가 페이지 선언에 맞지 않는다
페이지를 실행하지 않고, 선언이 낸 에러 메시지를 그대로 돌려줍니다.
redirect나 가드 거절, 토큰 없음
401 인증 챌린지입니다. 클라이언트는 포기하지 않고 로그인하러 갑니다.
redirect나 가드 거절, 토큰 있음
항상 같은 답이라 id가 존재하는지 확인해 주지 않습니다.
router.notFound(), 또는 읽는 도큐먼트가 없다
이 인자로는 화면이 없다고 답합니다.
그 밖의 예외
실제 에러는 서버 로그에만 남고 호출자에게는 설명하지 않습니다.
  • 목록은 promptBudget에 맞게 잘립니다. 기본값은 60,000자이고, 큰 목록부터 자르며 Attached the first N of M rows of <key>; call it for the rest. 안내가 붙습니다. 단일 도큐먼트는 자르지 않습니다.
  • 툴 노출 규칙은 그대로입니다. 가드가 정하고, mcp: false와 Person도 그대로 적용됩니다.
  • 페이지 안 채팅에는 앱 프롬프트가 나오지 않습니다. 내장 슬래시 커맨드 여섯 개만 있습니다.
  • API 전용 빌드에는 프롬프트가 없습니다. 프롬프트는 RSC worker가 답하는데, web: false에는 RSC worker가 없기 때문입니다.

5. 진행률 보고하기

오래 걸리는 툴 호출은 클라이언트에 진행률을 스트리밍할 수 있습니다. 실제 작업이 일어나는 곳에서 보고하세요. 스트리밍 호출이 아니면 report는 아무것도 하지 않으므로, 같은 서비스가 HTTP, 웹소켓, 테스트에서 그대로 동작합니다:
apps/myapp/lib/task/task.service.ts
  • 클라이언트가 요청해야 합니다. Accept: text/event-stream과 _meta.progressToken을 모두 보내야 하며, 스트리밍은 tools/call에만 있습니다.
  • 스트림은 첫 보고 때 열립니다. 한 번도 보고하지 않는 호출은 일반 JSON으로 답합니다.
  • 취소는 클라이언트가 스트림을 닫는 것입니다. 이후 보고는 버려지지만, 이미 실행 중인 exec을 프레임워크가 멈추지는 못합니다. 기다리는 쪽 없이 끝까지 실행됩니다.
  • McpProgress.streaming은 누군가 읽는 동안 true입니다. 만들기 비싼 메시지는 그때만 조립하세요.

인증과 권한

MCP는 HTTP로 들어와 평소의 파이프라인을 그대로 탑니다. 가드, Self, account 미들웨어는 브라우저 호출과 똑같이 동작합니다. 다른 점은 하나, cookie 헤더를 입구에서 버리므로 받는 자격 증명은 Authorization 헤더뿐입니다.
호출자에게 보이는 목록
모든 가드는 기본값 없이 static scope를 선언합니다. 이 값이 가드가 호출자의 목록에서 항목을 숨길 수 있는지 정합니다:
가드
목록에서 숨김
호출 때 검사
scope: "account"
SignedIn · Admin · Every
✓
✓
호출자만 보고 판정하므로, 익명 에이전트에게 실패할 수밖에 없는 관리자 툴을 내밀지 않습니다.
scope: "resource"
Can<Verb><Model> · SelfOrAdmin
✓
호출 인자가 있어야 판정하므로, 항목은 목록에 남고 호출 단계에서 막힙니다.
✓예아니오
목록은 편의를 위한 필터일 뿐이고, 호출은 여전히 모든 가드를 거칩니다.
토큰은 어디서 오나
libs/shared를 마운트한 앱
설정할 것이 없습니다. 앱이 OAuth 2.1 인가 서버(메타데이터, 동의, 클라이언트 등록, 토큰, 폐기)를 직접 제공하고 자신을 issuer로 둡니다.
/.well-known/oauth-authorization-server
외부 issuer
아래 env 세 개로 /mcp를 그 issuer에 연결합니다. issuer를 지정하는 순간 /mcp가 토큰을 요구합니다.
AKAN_MCP_AUTH_SERVERS
외부 issuer를 쓸 때는 배포 env에 다음을 설정합니다:
OAuth 리소스 서버, env로
  • 자격 증명이 없을 때. issuer를 지정하면 bearer 없는 모든 요청이 WWW-Authenticate와 함께 401을 받고, 지정 전에는 가드가 거절한 호출만 받습니다. 어느 쪽이든 클라이언트는 툴이 없다고 결론짓지 않고 로그인합니다.
  • scope는 외부 issuer용입니다. insufficient_scope는 AKAN_MCP_SCOPES를 설정했을 때만 검사합니다. libs/shared를 마운트한 앱이 직접 발급하는 토큰에는 scope claim이 없으므로, 그런 앱에 설정하면 그 토큰이 모두 403으로 거절됩니다.
  • audience 검사. aud가 없는 토큰은 issuer가 지정된 뒤부터 거부되고, 지정되지 않은 동안은 통과합니다. 다른 리소스를 가리키는 aud는 항상 거부됩니다.
  • 서명. env만으로는 토큰 서명을 검사하지 못합니다. setMcp()로 auth.verify를 넘겨야 위조 토큰이 익명 호출자로 읽히지 않고 거부됩니다. libs/shared는 자기 토큰에 이렇게 합니다.
libs/shared를 마운트한 앱에서 에이전트가 토큰을 받는 과정은 에이전트를 위한 OAuth에서 단계별로 다룹니다.

꿀팁

  • 툴이 안 보이면 부팅 로그를 보세요. 빠뜨릴 opt-in 자체가 없어서 답은 그 로그에만 있습니다. MCP catalogue: tools=… 아래에 거부된 엔드포인트마다 이유가 한 줄씩 나오는데, 둘 다 기본 로그 레벨보다 낮으니 AKAN_PUBLIC_LOG_LEVEL=verbose로 켜세요.
  • 딕셔너리 .of()에 모델의 .desc()를 적으세요. 생성된 CRUD 툴은 "Get X" 뒤에 그 설명을 붙이고, 루트 목록과 insight는 .of()의 라벨과 설명을 그대로 씁니다. 이 항목들이 가질 수 있는 문구는 그것뿐입니다.
  • 비용을 보고 줄이세요. MCP는 항목 사이의 $ref를 금지하므로, 항목마다 언급한 모델의 스키마를 통째로 인라인하고 그 목록 전체를 접속하는 에이전트마다 다시 보냅니다. 시그널별 MCP catalogue cost: 줄이 바이트가 어디에 쓰였는지 알려 주고, 보통 mcp: { cru: false }가 가장 큰 효과를 냅니다.
  • 호출자의 실수는 호출자의 실수로 돌아갑니다. 선언하지 않은 인자는 Unknown argument "x"., 없는 도큐먼트는 No <model> found for the arguments given.로 답합니다. 진짜 장애만 서버가 실패했다고 답합니다.
  • 덩치 큰 필드에는 field.visual을 쓰세요. 모든 MCP 결과와 readable schema에서 함께 빠지므로 둘이 어긋나지 않습니다.
에이전트를 위한 OAuth→
에이전트의 토큰이 어디서 오고 어떻게 폐기하는지 다룹니다.
인페이지 에이전트→
내 페이지 안의 채팅입니다. MCP와는 다른 표면입니다.

이 페이지

MCP 서버
1. 서버 켜기
2. 엔드포인트 작성하기
3. 슬라이스와 CRUD
4. 화면을 프롬프트로 게시하기
프롬프트를 실행할 수 없을 때
5. 진행률 보고하기
인증과 권한
꿀팁