Skip to main content
Glama

onenote-mcp

Microsoft Graph를 통해 Microsoft OneNote를 노출하는 MCP 서버입니다. 노트북 및 섹션 구조, 페이지 콘텐츠, 그리고 호출 모델이 읽을 수 있는 이미지로 렌더링된 필기 내용을 제공합니다.

project-spec.md이(가) 권위 있는 설계 문서입니다. 잉크 재구성 파이프라인, 두 개의 독립적인 OAuth 계층, Cloud Run 배포 모델, Firestore 기반 토큰 캐시를 다룹니다. 여기서 무엇이든 변경하기 전에 먼저 읽으십시오.

요구 사항

Node >= 24. @google-cloud/firestore는 Node >= 22가 필요하며, Node 24가 현재 Active LTS입니다.

Related MCP server: OneNoteMCP

빠른 시작

npm ci
npm run build
npm test

스크립트

스크립트

수행 작업

npm run build

src/를 dist/로 컴파일

npm run typecheck

출력 없이 타입 검사

npm run dev

--watch로 소스에서 서버 실행

npm start

dist/에서 컴파일된 서버 실행 (build 먼저 실행)

npm run bootstrap

Firestore 토큰 캐시를 시드하는 로컬 디바이스 코드 로그인

npm test

test/**/*.test.ts에 대해 node --test 실행

테스트는 test/에 있으며 src/를 미러링합니다. Node의 네이티브 타입 스트리핑을 사용하여 TypeScript 소스에 대해 직접 실행되므로 npm test는 빌드가 필요 없습니다. 이는 소스에 제약을 줍니다: enum 없음, namespace 없음, 생성자 매개변수 속성 없음, 타입 전용 임포트는 import type으로 작성해야 합니다. erasableSyntaxOnly 및 verbatimModuleSyntax 컴파일러 옵션이 이를 강제합니다.

디렉터리 레이아웃 및 관련 규칙은 CLAUDE.md를 참조하십시오.

토큰 캐시

src/token-cache.ts는 단일 Firestore 문서에 대해 MSAL의 ICachePlugin을 구현하며, 해당 문서의 경로는 FIRESTORE_CACHE_DOC에서 가져옵니다. beforeCacheAccess는 문서의 cache 필드를 읽고 문자열을 MSAL에 전달합니다. afterCacheAccess는 직렬화된 캐시를 Firestore 트랜잭션 내에서 다시 쓰며, MSAL이 캐시가 변경되었다고 보고할 때만 수행합니다. 존재하지 않는 문서는 빈 캐시로 읽히며, 이는 npm run bootstrap이 실행되기 전의 상태입니다. 두 진입점 모두 동일한 플러그인을 사용합니다: 부트스트랩 CLI는 이를 통해 캐시를 쓰고 서버는 이를 통해 읽으므로, 하나의 직렬화기만 있고 두 번째 형식을 동기화할 필요가 없습니다.

그 blob이 리프레시 토큰의 유일한 사본이므로, 두 가지가 이를 보호합니다.

문서를 비우는 쓰기는 거부됩니다. MSAL은 일부 실패 시 메모리 내 캐시에서 자격 증명을 제거하며, afterCacheAccess는 MSAL의 finally 블록 내에서 실행되므로 계정을 잃은 직렬화가 저장된 것이 여전히 유효한 동안 이 코드에 도달할 수 있습니다. overwriteWouldEmptyCache가 이를 중지하고 {"event":"token-cache-write-refused"}를 기록합니다. 비어 있음 검사는 MSAL 키 이름을 읽지 않습니다. 캐시는 모든 값이 빈 컨테이너인 객체로 파싱될 때 비어 있는 것으로 간주되므로, MSAL이 형식을 변경해도 반전되지 않으며, 인식하지 못하는 것은 차단되지 않고 통과됩니다.

각 쓰기가 대체하는 blob은 previousCache 필드에 보관됩니다. 한 세대, 즉 기록이 아닙니다: 캐시는 모든 리프레시마다 다시 쓰이며, 유용한 사본은 항상 가장 최근의 좋은 것입니다. 잘못된 쓰기에서 복구하는 것은 Firestore 콘솔에서 해당 필드를 cache에 복사하는 것입니다. 이는 대안이 디바이스 코드 로그인이기 때문에 가치가 있습니다. 두 번째 계층을 위해 특정 시점 복구를 켜십시오:

gcloud firestore databases update --enable-pitr

백엔드 실패는 자격 증명 실패가 아닙니다. Firestore에 연결할 수 없거나 roles/datastore.user 바인딩이 해지되면 TokenCacheUnavailableError가 발생하며, 죽은 리프레시 토큰이 생성하는 오류로 표면화되지 않습니다. 쓰기는 그 전에 세 번 재시도됩니다. 구분이 코드만큼 가치 있는 이유는 아래 표의 cache-unavailable 행을 참조하십시오.

npm test는 문서 스냅샷을 디코딩하는 함수인 readCache만 다룹니다. 두 콜백, 트랜잭션 및 createFirestoreTokenCachePlugin은 자동화된 테스트가 없습니다. Firestore 백엔드가 필요합니다. 이를 실행하려면 에뮬레이터가 필요하며, PATH에 java가 있어야 하고 자체 설치가 필요합니다:

sudo apt-get install google-cloud-cli-firestore-emulator

gcloud components install cloud-firestore-emulator는 Debian 패키지된 Google Cloud CLI에 설치하지 않습니다. 해당 빌드에서는 구성 요소 관리자가 비활성화되어 있으며, gcloud는 위의 apt-get 명령을 대신 출력합니다.

Graph 인증

src/graph-auth.ts는 시드된 토큰 캐시를 Microsoft Graph 액세스 토큰으로 변환합니다. createGraphAuth는 ONENOTE_CLIENT_ID, ONENOTE_AUTHORITY 및 Firestore 캐시 플러그인에서 하나의 PublicClientApplication을 빌드하고 프로세스 수명 동안 보유합니다. getAccessToken()은 캐시된 계정을 읽고 acquireTokenSilent를 호출한 다음 토큰을 반환합니다. 요청된 범위는 Notes.Read 및 Notes.ReadWrite이며, 정규화되어 있습니다.

배포된 서버는 대화형으로 로그인하지 않습니다. 누구에게도 프롬프트를 표시할 방법이 없으며, Graph의 OneNote 엔드포인트는 앱 전용 인증을 지원하지 않으므로 저장된 리프레시 토큰이 죽으면 대체 수단이 없습니다. 인간이 npm run bootstrap을 다시 실행합니다. 따라서 모든 실패는 GraphAuthError로 표시되며, 호출자에게 Graph의 원시 401로 도달하는 원시 MSAL 오류가 아닙니다:

reason

발생 상황

수행할 작업

cache-unreadable

Firestore 문서가 없거나, 해당 cache 필드가 MSAL이 역직렬화할 수 없는 것

npm run bootstrap

cache-unavailable

Firestore가 응답하지 않았거나, 런타임 서비스 계정이 roles/datastore.user를 잃음

재시도. 로그인 아님.

no-account

캐시는 읽혔지만 로그인된 계정이 없음

npm run bootstrap

silent-failed

저장된 리프레시 토큰이 만료되었거나 해지되었거나, 토큰 엔드포인트가 사용 가능한 것을 반환하지 않음

npm run bootstrap

cache-unavailable은 그 가치를 입증하는 행입니다. Firestore는 캐시 플러그인을 통해 acquireTokenSilent 내에서 읽고 쓰이므로, 백엔드 중단은 죽은 리프레시 토큰이 생성하는 것과 동일한 거부로 도착하곤 했습니다. 그리고 그 메시지는 운영자에게 브라우저로 가서 작동 중인 자격 증명을 교체하라고 지시합니다. GraphAuthError.retryable은 그 구분을 전달하며, 해당 이유만이 이를 설정합니다.

이 모든 것은 또한 stderr에 한 줄을 씁니다:

{"event":"graph-auth-failure","reason":"silent-failed","documentPath":"tokencache/msal","retryable":"false"}

그 줄이 핵심입니다. 도구 실패는 그 외에는 Claude 대화 내에서만 나타나므로, 그것 없이는 운영자에게 커넥터가 작동을 멈췄다는 것을 알려주는 것이 없습니다. 아래 경고를 참조하십시오.

메시지는 문서 경로와 기본 MSAL 오류를 명명하며, 의도적으로 계정 식별자를 포함하지 않습니다: username은 사용자의 UPN이고 homeAccountId는 테넌트 ID를 포함하며, 둘 다 로그에 속하지 않습니다.

npm test는 가짜 클라이언트를 통해 획득 로직을 다룹니다. createGraphAuth 자체에는 자동화된 테스트가 없습니다. 실제 디바이스 코드 로그인으로 시드된 캐시가 필요하며, 시드할 수 있는 자격 증명은 커밋될 수 없습니다. npm run bootstrap을 실행한 다음 동일한 문서에 대해 서버를 실행하여 이를 실행하십시오. 그 소비자는 아래의 Graph 구조 클라이언트입니다. 아직 createApp에 둘 다 연결하는 것은 없습니다.

Graph 구조

src/graph-structure.ts는 OneNote 트리를 읽습니다: 노트북, 섹션 그룹, 섹션 및 한 섹션 내의 페이지 목록. new GraphStructure(auth)는 getAccessToken()이 있는 모든 것을 받으므로 서버는 위의 GraphAuth를 전달합니다.

메서드

반환

listNotebooks()

표시 이름별 모든 노트북

listSections(containerKind, containerId)

노트북 또는 섹션 그룹 바로 아래의 섹션

listSectionGroups(containerKind, containerId)

노트북 또는 섹션 그룹 바로 아래의 섹션 그룹

listContainerChildren(containerKind, containerId)

위의 둘 다, 함께 가져옴

listPagesInSection(sectionId, top?)

한 섹션의 페이지, 가장 최근 수정 순, 최대 top (기본 50)

getNotebookTree(notebook)

중첩된 모든 섹션 그룹이 해결된 하나의 노트북

getFullTree()

각각 트리가 해결된 모든 노트북

getExpandedTree()

섹션과 한 수준의 섹션 그룹을 가진 모든 노트북, 단일 요청으로

findSectionsByName(displayName)

계정 어디에서나 이름에 해당 텍스트가 포함된 섹션, 각각의 부모 노트북 및 섹션 그룹 포함

findPagesByTitle(sectionId, title)

제목이 일치하는 한 섹션의 페이지, Graph에 의해 대소문자 구분 없이 비교됨

containerKind는 notebooks 또는 sectionGroups입니다. 두 Graph 관계 이름입니다. 두 컨테이너 종류 모두 동일한 자식 관계를 노출하므로, 목록 메서드는 두 번 존재하는 대신 종류를 받습니다.

getExpandedTree()는 저렴한 것입니다. Graph에 관계를 걸어가며 탐색하는 대신 확장하도록 요청합니다:

GET /me/onenote/notebooks?$select=id,displayName
    &$expand=sections($select=id,displayName),
             sectionGroups($select=id,displayName;$expand=sections($select=id,displayName))

54개 노트북 계정을 기준으로 측정: 하나의 요청과 78KB, getFullTree()의 195개 요청과 비교. OneNote가 시간당 400개 요청과 5개 동시 요청을 허용하므로 중요합니다. 각 확장 절 내부의 $select가 응답을 441KB에서 78KB로 줄이며, $select와 $expand를 모두 포함하는 절 내부의 구분자는 세미콜론입니다. 도달하지 못하는 것은 섹션 그룹 내부에 중첩된 섹션 그룹입니다. Graph는 $expand 중첩을 두 수준으로 제한하므로, findSectionsByName은 대신 계정 전체 섹션 목록을 필터링하고 각 섹션의 부모를 확장하여 한 요청으로 해당 경우를 다룹니다.

api-overview.md는 이러한 엔드포인트가 허용하는 것, 서비스가 자체 문서와 모순되는 위치를 포함하여 기록합니다.

단일 Graph 호출이 처리하지 못하는 세 가지를 순회가 처리합니다:

  • 중첩. 섹션 그룹은 UI의 "탭 그룹"이며, 추가 섹션 그룹을 포함합니다. getNotebookTree는 재귀합니다.

  • 페이지 매김. 모든 목록 호출은 @odata.nextLink가 나타나지 않을 때까지 따릅니다. Graph는 자체 페이지 크기를 선택하고 더 큰 $top을 무시하므로, 하나의 응답이 컬렉션이 완전하다는 증거는 아닙니다. listPagesInSection은 top 항목이 손에 들어오는 즉시 중지하므로, top은 페이지 크기가 아닌 결과 수입니다.

  • 계정 전체 페이지 목록은 호출되지 않습니다. GET /me/onenote/pages는 노트북-연도 구조에서 오류 20266, "최대 섹션 초과"로 실패합니다. 페이지 목록은 항상 /me/onenote/sections/{id}/pages로 범위가 지정되며, 테스트는 src/에서 계정 전체 경로를 검사합니다.

실패는 2xx가 아닌 응답의 경우 GraphRequestError입니다. 이 오류는 status, statusText, 응답 body를 담고 있습니다. 오류 20266은 해당 텍스트로만 다른 400과 구분되기 때문입니다. 그리고 2xx 응답이지만 본문이 예상된 형태가 아니거나, 목록이 종료되지 않거나, 섹션 그룹이 20단계 이상 중첩된 경우에는 GraphResponseError입니다. 어떤 메시지에도 노트북, 섹션 또는 페이지 이름이 포함되지 않습니다.

npm test는 정확한 URL을 키로 하는 가짜 fetch를 통해 모든 것을 구동합니다. 이것이 확인할 수 없는 것은 Graph가 해당 URL을 수락하는지 여부입니다. 쿼리 문자열은 project-spec.md의 부록 A에 있는 검증된 recon 스크립트에서 비롯되며 실제 테넌트에 대해 실행해야만 확인됩니다.

잉크

Graph의 일반 페이지 콘텐츠 엔드포인트는 필기를 삭제하고 <!-- InkNode is not supported -->를 남깁니다. 또한 Graph는 페이지를 이미지나 PDF로 내보낼 수 없습니다. 따라서 필기는 원시 스트로크 데이터에서 재구성됩니다: GET /me/onenote/pages/{id}/content?includeInkML=true는 multipart/mixed로 응답하며, 한 부분은 동일한 HTML이고 다른 부분은 InkML입니다. 스트로크는 SVG가 되고 그다음 PNG가 되어 호출 모델에 이미지로 전달되어 자체 비전으로 읽습니다. OCR 서비스는 관여하지 않습니다.

모듈

기능

src/multipart.ts

splitMultipart(body, contentType) → 부분들, 응답이 multipart가 아닌 경우 null

src/ink.ts

parseInkStrokes(text) → 스트로크; strokesToSvg; rasterizeSvg; renderInk(text, width?) → PNG 또는 null

src/page-content.ts

GraphPageContent.fetchRaw(pageId) → 분할된 응답; .fetchInk(pageId) → PNG 또는 null

이것이 전혀 작동하는지 여부를 결정하는 네 가지 세부 사항이 있으며, 네 가지 모두 project-spec.md의 부록 A에 있는 검증된 recon 스크립트에서 비롯됩니다:

  • 네임스페이스가 제거됩니다. Graph는 inkml:ink, inkml:trace, inkml:traceFormat을 내보냅니다. fast-xml-parser는 removeNSPrefix: true로 구성되며 모든 조회는 접두사 없는 이름을 사용합니다.

  • 채널 순서는 <traceFormat>에서 비롯됩니다. 이 계정의 점은 X, Y, F이며, F는 펜 압력입니다. 각 점의 처음 두 숫자를 읽으면 압력이 좌표로 그려집니다.

  • 좌표는 himetric입니다. px = himetric * 96 / 2540. 이것은 페이지 HTML이 입력된 콘텐츠를 배치하는 것과 동일한 좌표 공간이므로 잉크와 입력된 콘텐츠는 나중에 산술로 서로 등록할 수 있습니다.

  • 트레이스는 트리의 어디에나 있습니다. 페이지에는 둘 이상의 <ink> 루트가 있을 수 있으며 <traceGroup> 요소는 중첩됩니다. 모두 수집됩니다.

잉크가 없는 페이지는 null로 렌더링됩니다. 이것은 입력된 페이지의 일반적인 응답이며 오류가 아닙니다. 실제로 발생하는 실패는 50단계 이상 중첩된 트레이스 그룹에 대한 InkParseError와 resvg가 거부하는 문서에 대한 InkRenderError입니다. 두 메시지 모두 문서를 재현하지 않습니다. 스트로크 좌표는 사용자의 필기이기 때문입니다.

test/fixtures/*.inkml은 수작업으로 작성되었습니다 — 몇 개의 스트로크, X/Y/F 채널 순서, himetric 단위, 두 개의 <ink> 루트와 중첩된 <traceGroup> 요소가 있는 파일 하나. 캡처된 페이지 덤프는 커밋할 수 없습니다. 렌더링된 잉크는 완전히 읽을 수 있는 개인 메모이기 때문입니다.

MCP 엔드포인트

서버는 상태 비저장 Streamable HTTP를 통해 POST /mcp에서 MCP를 말합니다. 모든 요청은 자체 MCP 서버를 구축하고 응답한 다음 해체합니다. 다음 요청까지 아무것도 유지되지 않습니다. 세션 ID가 없으며 SSE도 없습니다 — GET /mcp 및 DELETE /mcp는 405로 응답하고 POST는 스트림을 여는 대신 JSON 본문으로 응답합니다. 열린 스트림은 Cloud Run 인스턴스를 계속 살려두고 유휴 시간에 대해 비용을 청구합니다.

curl -s -X POST localhost:8080/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
# {"result":{"tools":[]},"jsonrpc":"2.0","id":1}

Streamable HTTP 사양에서는 이 서버가 스트리밍하지 않더라도 두 Accept 유형이 모두 필요합니다. src/tools.ts의 createTools는 레지스트리입니다 — 여섯 개의 탐색 도구(list_notebooks, list_sections, list_pages, search_pages, find_page_by_name, list_pages_by_name), 하나의 읽기 도구(get_page_content), 다섯 개의 쓰기 도구(append_to_page, append_to_page_by_name, create_page, create_page_by_name, update_page_title) — 그리고 src/mcp-server.ts는 그 주변의 JSON-RPC 표면입니다.

도구가 예외를 던지면 isError: true와 읽을 수 있는 메시지가 있는 도구 결과로 돌아옵니다 — 만료된 새로 고침 토큰, 사라진 페이지, resvg가 거부하는 문서는 모두 정상적인 결과이지 프로토콜 오류가 아닙니다. 등록되지 않은 도구에 대한 호출만 JSON-RPC 오류입니다.

모든 요청은 하나의 JSON 로그 줄을 작성합니다: HTTP 동사, 경로, 상태, 기간, JSON-RPC 메서드, 그리고 tools/call의 도구 이름. 쿼리 문자열, 헤더, 인수 또는 결과는 절대 포함하지 않습니다 — src/logging.ts 참조.

/mcp는 베어러 토큰 뒤에 닫혀 있습니다 — MCP 엔드포인트의 베어러 토큰 참조. 상태 엔드포인트는 열려 있습니다.

OAuth 검색

Claude는 흐름을 시작하기 전에 권한 부여 서버를 찾아야 합니다. src/oauth-router.ts는 SDK의 mcpAuthRouter를 애플리케이션 루트에 마운트합니다 — 발급자 URL에서 경로를 구성하므로 접두사 뒤에 둘 수 없습니다 — 그리고 다섯 개의 경로를 제공하며, 모두 필연적으로 인증되지 않습니다:

경로

설명

GET /.well-known/oauth-authorization-server

RFC 8414 권한 부여 서버 메타데이터

GET /.well-known/oauth-protected-resource/mcp

RFC 9728 보호 리소스 메타데이터

GET,POST /authorize

권한 부여 엔드포인트

POST /consent

동의 양식이 다시 게시되는 곳; SDK의 /authorize 라우터는 재개 경로를 소유하지 않음

POST /token

토큰 엔드포인트

curl -s localhost:8080/.well-known/oauth-authorization-server
curl -s localhost:8080/.well-known/oauth-protected-resource/mcp

두 문서의 모든 것은 MCP_PUBLIC_URL에서 파생됩니다: 그것은 발급자이고 resource 식별자는 그것에 /mcp를 더한 것입니다. MCP_PUBLIC_URL은 시작 시 후행 슬래시가 있으면 거부되므로 경로를 연결하여 만든 모든 URL이 잘 구성됩니다. issuer 필드는 URL 정규화된 형태를 보고하며, origin 전용 값의 경우 동일한 문자열에 후행 슬래시가 다시 추가된 것입니다. 보호 리소스 문서는 경로 접미사가 있는 URL에서만 제공됩니다 — 베어 /.well-known/oauth-protected-resource는 404이고 /.well-known/openid-configuration도 404입니다. Claude는 접미사가 있는 경로를 먼저 탐색합니다.

scopes_supported는 offline_access를 나열하며, 이는 액세스 토큰이 만료될 때마다 재동의하는 대신 새로 고침 토큰을 요청하도록 만드는 것입니다. registration_endpoint는 없습니다. 클라이언트 ID와 비밀번호가 구성되어 있으므로 동적 클라이언트 등록은 할 일이 없습니다. 하나의 클라이언트가 등록되어 있으며 세 개의 리디렉션 URI가 있습니다 — 호스팅된 Claude 표면용 https://claude.ai/api/mcp/auth_callback, 그리고 Claude Code용 http://localhost/callback 및 http://127.0.0.1/callback (포트는 RFC 8252에 따라 무시됨).

GET /authorize는 리디렉션 대신 동의 페이지를 렌더링합니다: 부여되는 내용과 권한 부여 코드가 전송될 호스트를 명명하는 하나의 승인 버튼. 승인하면 POST /consent로 다시 게시되며, 60초 동안 유효한 일회용 코드를 발행하고 클라이언트의 콜백으로 리디렉션합니다. 전체 권한 부여 요청은 MCP_TOKEN_SIGNING_KEY로 서명된 하나의 숨겨진 필드로 해당 페이지를 통과하므로 동의 중 인스턴스 교체가 흐름을 깨지 않으며 양식을 편집할 수 없습니다. 검증에 실패한 필드는 리디렉션 없이 코드 발행 없이 400입니다.

두 동의 응답 모두 Cache-Control: no-store, Referrer-Policy: no-referrer — 양식은 쿼리 문자열에 state와 PKCE 챌린지가 있는 /authorize URL에서 게시됩니다 — X-Frame-Options: DENY, 그리고 default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'의 CSP를 전달합니다. 의도적으로 form-action이 없습니다. 브라우저는 리디렉션 대상에 대해 검사되는지 여부에 대해 의견이 분분했으며 동의 POST는 claude.ai로의 리디렉션으로 응답합니다.

POST /consent에는 자체 속도 제한이 있습니다 — 15분에 200회 — SDK의 /authorize 제한기보다 앞에 의도적으로 마운트되고 렌더링된 양식은 10분 동안 게시 가능한 상태로 유지되므로 /authorize를 한 번 통과하면 재생할 수 있는 필드가 생성됩니다. 제한은 100개 항목의 보류 코드 상한 위에 있어 저장소 자체의 축출(동작이 지정됨)이 버스트가 먼저 부딪히는 것입니다.

POST /token은 1시간 동안 유효한 액세스 토큰과 30일 동안 유효한 새로 고침 토큰을 발행합니다. 둘 다 MCP_TOKEN_SIGNING_KEY 아래의 간결한 페이로드에 대한 HMAC-SHA256이며 다른 것은 없습니다 — 검증을 위해 저장소를 참조하지 않으므로 Cloud Run 개정 교체가 재연결을 강제하지 않습니다. 페이로드는 대상(audience)을 전달하며, 이는 MCP_PUBLIC_URL에 /mcp를 더한 것이므로 토큰은 이 MCP 엔드포인트에만 유효합니다. 이러한 토큰이 얼마나 오래 지속되는지, 그리고 인간이 더 자주 승인하도록 만드는 방법은 두 섹션 아래에 있습니다.

MCP 엔드포인트의 베어러 토큰

/mcp에 대한 모든 요청에는 Authorization: Bearer <access token>이 필요합니다. SDK의 requireBearerAuth는 createApp의 MCP 라우터 앞에 있으며 src/oauth-provider.ts의 verifyAccessToken이 호출하는 것입니다: MCP_TOKEN_SIGNING_KEY 아래의 HMAC 서명, 토큰 종류, 만료, 대상. 올바르게 서명되고 만료되지 않았지만 다른 서버의 리소스 식별자를 전달하는 토큰은 거부됩니다 — SDK는 자체 대상을 확인하지 않으므로 해당 확인이 없으면 이 서명 키를 공유하는 서버가 다른 MCP 서버용으로 발행한 토큰이 수락됩니다.

토큰이 없거나, 만료되었거나, 해당 검사 중 하나라도 실패하는 요청은 챌린지 헤더와 함께 401입니다:

WWW-Authenticate: Bearer error="invalid_token", error_description="…",
                  resource_metadata="https://<MCP_PUBLIC_URL>/.well-known/oauth-protected-resource/mcp"

resource_metadata 매개변수가 중요한 부분입니다. Claude가 권한 부여 서버를 찾고 흐름을 시작하는 방법이므로 그것 없이 401이면 로그인 프롬프트가 아닌 막다른 길입니다. Claude는 401에 반응적으로 새로 고치고 저장된 만료 몇 분 전에 사전에 새로 고치므로 여기서 401은 일반적인 이벤트입니다.

토큰은 Authorization 헤더에서만 읽히며 다른 곳에서는 읽히지 않습니다. 쿼리 문자열의 ?access_token=는 허용되지 않습니다 — MCP 권한 부여 사양이 금지하며 src/logging.ts는 그 근거로 로그 줄에서 쿼리 문자열을 제외합니다.

열려 있는 경로는 면제 목록이며 /mcp를 제외한 모든 것보다 깁니다. 전체 권한 부여 흐름이 아직 토큰이 없는 호출자에게 응답해야 하기 때문입니다: /healthz 및 /health, 두 .well-known 문서, /authorize, /consent, /token. test/server.test.ts의 테스트는 createApp이 실제로 등록하는 경로를 열거하고 해당 목록에 없는 모든 경로가 토큰 없이 401로 응답한다고 주장하므로 나중에 추가된 경로는 누군가 의도적으로 열지 않는 한 닫힙니다.

필요한 범위는 없습니다. 이 서버가 발행하는 유일한 범위인 offline_access는 호출자가 무엇을 할 수 있는지가 아니라 새로 고침 토큰이 부여되는지 여부에 관한 것이며, 이를 요구하면 그 외에는 유효한 토큰에 대해 403으로 응답할 것입니다. 범위 검사가 추가되면 403은 WWW-Authenticate: Bearer error="insufficient_scope"를 전달해야 합니다 — 이 미들웨어가 수행하는 작업 — Claude는 다른 403을 모두 종료로 취급하고 아무것도 묻지 않기 때문입니다.

토큰 수명 및 재검증 강제

이 서버는 무인 실행을 전제로 구축되었습니다. 기본 설정은 이를 반영하며, 유출된 자격 증명을 차단할 수 있는 일부 기능을 희생합니다. 중요한 환경에 배포하기 전에 이 문서를 읽고, 그 트레이드오프가 적절하지 않다면 수치를 변경하십시오.

기본 설정이 하는 일

토큰

수명

갱신 주체

액세스 토큰

1시간

리프레시 토큰, 자동으로

리프레시 토큰

30일

갱신할 때마다 새 30일이 적용된 새 토큰 발급

동의 양식

10분

없음; 만료된 양식은 거부되고 흐름이 다시 시작됨

Claude는 자체적으로 갱신합니다 — 1시간이 끝나기 전에 선제적으로, 그리고 401 응답 시 반응적으로. 따라서 사용자는 커넥터를 처음 추가할 때 Approve를 클릭하고, 이후에는 커넥터가 30일 동안 사용되지 않을 때만 다시 클릭합니다. 이것이 슬라이딩 윈도우입니다: 30일은 연결이 유휴 상태로 있을 수 있는 기간을 제한하지, 연결이 유지될 수 있는 총 기간을 제한하지 않습니다.

슬라이딩 방식의 이유와 비용

이 서버가 발급하는 모든 토큰은 상태 비저장(stateless)입니다. 서명된 페이로드일 뿐이며 — 데이터베이스 행도, 세션 기록도, 토큰이 돌아왔을 때 조회할 것이 아무것도 없습니다. 이것이 Cloud Run 개정판 교체를 보이지 않게 만드는 이유입니다: 새 인스턴스는 이전 인스턴스가 발급한 토큰을 검증하며, 두 인스턴스 간에 공유 상태가 없습니다. 토큰 저장소가 있다면 배포할 때마다 재연결이 필요할 것입니다.

비용은 개별적으로 폐기할 수 있는 것이 없다는 점입니다. 폐기 엔드포인트가 없는 이유는 삭제할 대상이 없기 때문입니다. 구체적으로:

  • 유출된 리프레시 토큰은 최대 30일간 접근을 허용하며, 사용할 때마다 보유자의 접근이 30일씩 연장됩니다. 무효화할 서버 측 기록이 없고, 도난된 리프레시 토큰과 정당한 토큰을 구별할 방법도 없습니다 — 둘 다 동일한 키로 서명된 동일한 바이트이기 때문입니다.

  • 윈도우를 슬라이딩하는 것은 로테이션이 아닙니다. 리프레시가 새 리프레시 토큰을 발급할 때, 교체된 토큰은 내부에 찍힌 만료 시각까지 계속 작동합니다. 진정한 로테이션은 이전 토큰을 사용 완료로 표시하는 것을 의미하며, 이 설계에는 없는 저장소가 필요합니다.

  • 액세스 토큰도 같은 이유로 1시간 안에 차단할 수 없습니다.

남은 것은 하나의 단순한 수단이며 즉시 작동합니다: MCP_TOKEN_SIGNING_KEY를 변경하고 재배포하십시오. 모든 액세스 토큰, 모든 리프레시 토큰, 모든 열린 동의 페이지가 즉시 무효화됩니다. 모두 해당 키로 검증되기 때문입니다. 다음 Claude 요청은 401을 받고 운영자는 Approve를 한 번 클릭합니다. 키를 정기적으로 로테이션하는 것은 그 자체로 합리적인 정책입니다.

동의 화면은 말하자면 누구도 인증하지 않습니다 — 버튼 하나가 있고 비밀번호가 없습니다. 낯선 사람과 사용자의 노트북 사이를 가로막는 것은 POST /token이 요구하는 MCP_OAUTH_CLIENT_SECRET, 모든 인증 코드를 claude.ai 또는 루프백으로 보내는 리다이렉트 URI 허용 목록, 그리고 코드를 흐름을 시작한 클라이언트에 바인딩하는 PKCE입니다.

사람이 더 자주 승인하도록 만들기

각 항목은 구성 값이 아닌 소스 변경입니다. 이는 의도적입니다: 윈도우를 단축하는 운영자는 배포의 보안 태세를 변경하는 것이며, 이는 누군가 잊을 수 있는 환경 변수보다는 누군가 읽을 수 있는 커밋에 속합니다.

유휴 윈도우를 단축합니다. src/oauth-provider.ts에서:

const REFRESH_TOKEN_TTL_S = 30 * 24 * 60 * 60;   // 30 days
const REFRESH_TOKEN_TTL_S = 7 * 24 * 60 * 60;    // a week

그 기간 동안 사용되지 않으면 커넥터는 클릭이 필요합니다. 정기적으로 사용되면 여전히 묻지 않습니다 — 윈도우가 계속 앞으로 밀리기 때문입니다. 이는 유출된 리프레시 토큰이 유출 사용이 중단된 후 생존하는 기간을 제한하며, 그 이상도 이하도 아닙니다.

윈도우 슬라이딩을 중지합니다. 이것이 이슈 #22가 원래 명시한 것이며, 연결의 유휴 시간이 아닌 총 수명을 제한합니다: 커넥터가 아무리 바빠도 사람이 30일마다 승인합니다. src/oauth-provider.ts의 exchangeRefreshToken에서 한 줄:

// Sliding: a new refresh token, 30 days from now.
return issueTokens(client.client_id, requested, mintRefreshToken(client.client_id, granted));

// Fixed: hand back the same token, expiring 30 days after the consent click.
return issueTokens(client.client_id, requested, refreshToken);

리프레시 토큰 발급을 완전히 거부합니다. 가장 엄격한 설정: 사람이 매시간 승인합니다. 만료된 액세스 토큰을 갱신할 것이 없기 때문입니다. 두 가지 편집이 필요하며 둘 다 필요합니다 — 메타데이터 스위치만으로는 토큰 발급이 중지되지 않습니다.

  1. src/oauth-router.ts에서 SCOPES_SUPPORTED를 비웁니다. Claude는 메타데이터가 이를 광고할 때만 인증 요청에 offline_access를 추가하며, 이것이 리프레시 토큰을 요청할지 결정하는 스위치입니다.

  2. src/oauth-provider.ts에서 issueTokens가 반환하는 것에서 refresh_token 필드를 제거합니다. 현재는 요청된 스코프와 관계없이 발급됩니다.

이 설정은 사용 시 눈에 띌 것입니다: 1시간이 끝나면 Claude가 브라우저를 세션 중간에 동의 화면으로 보냅니다.

액세스 토큰을 단축합니다. src/oauth-provider.ts의 ACCESS_TOKEN_TTL_S는 유출된 액세스 토큰이 작동하는 윈도우를 좁힙니다. 만료당 토큰 요청 한 번이 들고 사람의 개입은 전혀 없으므로 저렴합니다 — 그러나 유출된 리프레시 토큰에 대해서는 아무것도 하지 않으며, 리프레시 토큰이야말로 걱정할 가치가 있는 자격 증명입니다.

Keepalive

Microsoft의 위임된 리프레시 토큰은 약 90일간 사용되지 않으면 만료됩니다. 토큰은 실제로 교환될 때만 앞으로 밀리며, 보유한 액세스 토큰이 만료된 후 도구 호출이 도착할 때만 교환됩니다 — 따라서 3개월 동안 아무도 사용하지 않는 커넥터는 브라우저에서 npm run bootstrap을 실행해야 하는 사람이 필요한 커넥터입니다. 서버 내부의 어떤 것도 이를 자체적으로 방지할 수 없습니다. 아무도 호출하지 않을 때 서버 내부에서 실행되는 것이 없기 때문입니다.

POST /keepalive가 해결책입니다. forceRefresh: true로 acquireTokenSilent를 호출하여 보유한 액세스 토큰을 건너뛰고 리프레시 토큰을 교환하므로, Entra가 새 윈도우가 적용된 교체 토큰을 발급하고 src/token-cache.ts가 이를 Firestore에 기록합니다. forceRefresh가 핵심입니다: 이것이 없으면 MSAL이 자체 캐시에서 응답하고, Entra에 도달하는 요청이 없으며, 윈도우가 움직이지 않습니다.

MCP_KEEPALIVE_SECRET을 최소 32개의 임의 문자로 설정하면 라우트가 마운트됩니다. 설정하지 않으면 경로는 404를 반환합니다. 스케줄러는 X-Keepalive-Secret 헤더에 비밀을 제시하며, 어떤 작업이 수행되기 전에 상수 시간으로 비교됩니다. 베어러 토큰이 아닌 공유 비밀인 이유는 스케줄러가 OAuth 흐름을 실행할 수 없기 때문입니다 — 브라우저가 없고 리프레시 토큰을 보관할 곳도 없으며 — Layer-1 클라이언트 비밀이 아닌 별도의 변수인 이유는 전체 MCP 표면에 접근할 수 있는 자격 증명이 스케줄러 작업에도 함께 놓이지 않도록 하기 위함입니다.

자체 계정에서 설정하기

네 단계: 비밀 생성, 서비스에 적용, 작업 생성, 작업이 실제로 서비스에 도달하는지 확인. 전체가 선택 사항입니다 — 건너뛰어도 서버는 작동하며, 90일 동안 아무도 호출하지 않는 날까지는 문제없습니다.

# 1. Generate the secret. umask 077 so the file is not world-readable, and it is never
#    printed: the two commands below read it from disk.
umask 077
openssl rand -hex 32 > keepalive.secret

# 2. Get it onto the service. The GitHub secret alone changes nothing — the value
#    reaches the container as an env var, which happens on a deploy and only then.
gh secret set MCP_KEEPALIVE_SECRET < keepalive.secret
gh workflow run deploy.yml --ref main

# 3. Create the job. The API enable takes a minute or two to propagate; a create run
#    immediately after it fails with SERVICE_DISABLED, which is a retry rather than a
#    misconfiguration.
gcloud services enable cloudscheduler.googleapis.com --project="$GCP_PROJECT"

gcloud scheduler jobs create http onenote-mcp-keepalive \
  --project="$GCP_PROJECT" \
  --location="$GCP_REGION" \
  --schedule="0 4 * * 1" \
  --time-zone=UTC \
  --uri="$MCP_PUBLIC_URL/keepalive" \
  --http-method=POST \
  --headers="X-Keepalive-Secret=$(cat keepalive.secret)" \
  --attempt-deadline=60s \
  --max-retry-attempts=3

rm keepalive.secret

--location은 필수이며 스케줄러 자체의 리전으로, 작업 대상의 위치와는 관계가 없습니다. Cloud Run 리전을 사용하면 기억할 값이 하나 줄어듭니다. scripts/gcp-bootstrap.sh도 cloudscheduler.googleapis.com을 활성화하므로, 3단계의 활성화는 해당 줄이 추가되기 전에 부트스트랩된 프로젝트에서만 필요합니다.

그런 다음 증명하십시오. 모든 실패 모드가 조용하기 때문입니다 — 잘못된 비밀, 이전 URL을 가리키는 작업, 변수 없이 배포된 서비스는 모두 활성화된 스케줄러 작업처럼 보입니다:

gcloud scheduler jobs run onenote-mcp-keepalive --project="$GCP_PROJECT" --location="$GCP_REGION"

# An empty status code is success. A code is a gRPC status; the request log below says why.
gcloud scheduler jobs describe onenote-mcp-keepalive \
  --project="$GCP_PROJECT" --location="$GCP_REGION" \
  --format='value(status.code,lastAttemptTime,scheduleTime)'

# The request as the service saw it. 200 is the answer; 401 is a secret mismatch and 404
# means the deploy in step 2 did not happen.
gcloud logging read \
  'resource.type="cloud_run_revision" AND httpRequest.requestUrl:"keepalive"' \
  --project="$GCP_PROJECT" --limit=3 --freshness=10m \
  --format='value(timestamp,httpRequest.requestMethod,httpRequest.status,httpRequest.userAgent)'

200은 토큰이 교환되었음을 의미합니다. 저장되었음을 증명하는 것은 Firestore 문서의 updatedAt 필드가 실행 시각으로 이동하는 것이며, 이는 교체된 리프레시 토큰이 호출을 survived했는지에 대한 유일한 증거입니다. 이 배포에서 2026-08-19에 측정한 결과: 강제 실행이 200으로 응답했고 tokencache/msal이 14:15:53Z에서 14:30:54Z로 전진했습니다.

주간 실행은 90일 윈도우에 충분하며 여러 번의 누락된 실행을 위한 여유도 남깁니다. 작업 비용은 토큰 엔드포인트 왕복 한 번과 Firestore 쓰기 한 번입니다.

비밀을 로테이션하려면: 새 GitHub 비밀을 설정하고, 배포한 다음, gcloud scheduler jobs update http onenote-mcp-keepalive --update-headers=…를 실행합니다. 배포와 작업 업데이트 사이의 모든 실행은 401로 응답하고 아무 작업도 하지 않으며, 주간 일정에서는 그 윈도우에 아무것도 걸리지 않습니다.

헤더는 작업 정의에 있으며 프로젝트에 대한 roles/cloudscheduler.viewer가 있는 사람은 누구나 읽을 수 있습니다. 환경 변수가 Cloud Run 개정판 사양에서 읽을 수 있는 것과 정확히 같습니다. 이는 Deploy에서 Secret Manager를 사용하지 않기로 한 것과 동일한 트레이드오프입니다.

상태

의미

스케줄러가 해야 할 일

200

리프레시 토큰이 교환되고 새 토큰이 저장됨

아무것도 하지 않음

401

비밀이 없거나 잘못됨

작업 수정; 라우트는 아무 작업도 하지 않음

404

서비스에 MCP_KEEPALIVE_SECRET이 설정되지 않음

설정하고 재배포

503 및 "retryable": true

Firestore에 도달할 수 없음

재시도

503 및 "retryable": false

권한 부여가 만료됨

npm run bootstrap

이것이 보호하지 않는 것: 조건부 액세스 로그인 빈도 정책, 비밀번호 변경, MFA 재설정, 또는 관리자의 권한 부여 취소. 이 중 어떤 것도 일정과 관계없이 리프레시 토큰을 죽이며, 코드 변경으로 피할 수 없습니다. Entra 테넌트가 사용자의 것이라면 이 앱 등록을 로그인 빈도 정책에서 제외하십시오. 그렇지 않다면 90일을 다른 사람이 알리지 않고 단축할 수 있는 상한선으로 취급하십시오.

keepalive 라우트는 아래 토큰 수명의 Layer-1 30일 윈도우와도 무관합니다. 그 리프레시 토큰은 Claude의 커넥터 저장소에 있으며, Claude만이 이를 제시하거나 교체 토큰을 받을 수 있으므로 여기서 실행되는 어떤 것도 이를 유지할 수 없습니다. 이를 잃으면 Approve 버튼을 한 번 클릭하는 비용이 들고, Microsoft 토큰을 잃으면 디바이스 코드 로그인이 필요합니다.

페이지 미러 동기화

미러는 손으로 고른 노트북 세트의 페이지 사본을 Firestore에 유지하여 읽기 도구가 OneNote의 요청 예산을 소모하지 않고 응답할 수 있게 합니다. POST /sync가 이를 채우는 방법입니다. 자동화된 것은 없습니다: 비밀 없이는 라우트가 마운트되지 않고, MIRROR_READ_ENABLED 없이는 읽기 도구가 미러를 참조하지 않습니다.

최신 상태를 유지하는 방법

세 가지 메커니즘. 일정에 따라 실행되는 것은 두 가지뿐이며, 그중 하나는 임시적입니다.

실행 주체

적용 범위

초기 백필

완료 시 삭제되는 POST /sync 스케줄러 작업

모든 미러링된 노트북, 활성 여부와 무관, 한 번 채움

지속적 따라잡기

모든 적용 대상 읽기, 인라인으로, 응답 전에

활성 노트북에서 생성 및 편집된 페이지

삭제 정리

야간 스케줄러 작업 하나

활성 노트북에서 삭제된 페이지

정상 상태에서 예약된 증분 동기화는 없습니다. 읽기가 그 역할을 하기 때문입니다. 적용 대상 읽기 각각은 응답 전에 runIncremental을 자체적으로 실행하며, 최대 12개 요청과 15초, 30초에 한 번 이하로 실행됩니다 — 아래 읽기 섹션을 참조하십시오. 따라서 미러는 누군가 그것을 보려고 할 때 갱신되며 그 외에는 갱신되지 않습니다. 그 옆에 예약된 증분 실행이 있다면 시간당 동일한 400개 요청을 아무도 요청하지 않은 데이터를 갱신하는 데 소비할 것이고, 다음 읽기는 어차피 다시 갱신할 것입니다.

읽기는 삭제를 감지할 수 없으며, 이것이 바로 야간 스윕이 존재하는 이유입니다. 증분 패스는 상위 섹션의 lastModifiedDateTime을 기준으로 동작합니다 — 생성, 편집, 삭제 모두 이 값을 변경하며 — 그런 다음 해당 섹션에 변경된 페이지를 요청합니다. 삭제된 페이지는 어떤 목록에도 나타나지 않으므로, 그 응답에는 페이지가 사라졌다는 정보가 없습니다. 이를 알아내려면 섹션의 페이지 ID를 열거하고 미러와 대조해야 합니다. Graph는 어떤 OneNote 리소스에도 /delta를 제공하지 않고 삭제된 페이지에 대한 툼스톤도 없기 때문입니다. 이것이 스윕이며, 읽기 앞에 두기에는 너무 비싸고, 스윕이 실행되기 전까지 미러는 더 이상 존재하지 않는 페이지를 계속 제공합니다. 하룻밤이 이 방식이 허용하는 신선도 지연입니다.

백필은 둘 다 아니므로, 완료될 때까지 자체 일정을 갖습니다. 에포크 이후 섹션당 목록 한 번에 페이지당 콘텐츠 가져오기 한 번 — 이 계정에서는 수천 건의 요청 — 이며, 인라인 새로고침의 12-요청 예산으로는 몇 주가 걸릴 것입니다. sync/state.backfillComplete가 true가 되면 작업을 삭제하세요. 이는 모든 미러링된 섹션이 null이 아닌 pagesSyncedThrough를 가질 때입니다. 다른 어떤 것도 POST /sync를 호출하지 않습니다.

업그레이드 후 첫 구조 쓰기는 모든 문서를 한 번씩 다시 씁니다. 구조 문서에는 트리가 해당 문서에 대해 말한 내용을 나타내는 identity 문자열이 있으며, 해당 필드가 존재하기 전에 작성된 문서에는 이 값이 없습니다 — 따라서 일치하지 않아 다시 쓰여집니다. Firestore 쓰기 한 번의 패스이며 Graph 요청은 없고, 한 번만 발생합니다.

비활성 노트북은 한 번 백필된 후 동결됩니다. 선택 문서의 activeNotebookIds가 그 경계를 그립니다 — 미러링할 항목 선택 참조. 활성이 아닌 선택된 노트북은 백필로 채워지고 이후 영원히 미러에서 제공됩니다. 인라인 새로고침도 스윕도 다시 방문하지 않습니다. 이것이 55개 노트북 계정을 미러링 가능하게 만드는 이유입니다: 아카이브는 한 번 목록화되고 한 번 가져온 후 다시는 접근되지 않습니다.

네 개의 라우트

POST /sync             incremental, active notebooks only
POST /sync/sweep       scoped sweep, active notebooks only
POST /sync/sweep/full  every section of every active notebook
POST /sync/sweep/all   every section of every mirrored notebook, active or not

이 중 두 개는 일정에 따라 실행됩니다: 백필이 실행되는 동안의 /sync, 그리고 야간의 /sync/sweep/full. /sync/sweep은 더 저렴한 범위 제한 변형으로, 수동으로 유용합니다. /sync/sweep/all은 활동 필터가 없는 유일한 모드이자 비활성 노트북 내부의 삭제를 감지할 수 있는 유일한 방법입니다. 미러링된 섹션당 요청 한 번이 들므로, 타이머가 아닌 의도적으로 실행하세요.

비용

OneNote는 위임된 앱에 시간당 400회 요청을 허용하며, 모든 대화형 도구 호출도 동일한 400회에서 차감됩니다. 이 서비스가 실행되는 계정은 55개 노트북과 568개 섹션을 보유하고 있습니다. 4개 노트북과 202개 섹션이 미러링되고, 2개 노트북과 70개 섹션이 활성 상태입니다.

실행

요청 수

인라인 새로고침, 변경 없음

1 — 확장된 트리, 그 외에는 없음

인라인 새로고침, 일부 편집된 페이지

2–12; 실제로 변경된 페이지에만 가져오기, 12는 하드 예산

활성 노트북 대상 야간 /sync/sweep/full

여기서는 ~70, 변경된 페이지당 1회 추가

타임스탬프가 이동한 섹션 대상 /sync/sweep

방문한 섹션당 1회

모든 미러링된 노트북 대상 /sync/sweep/all

여기서는 ~202 — 시간당 예산의 절반

백필 실행 한 번

측정 결과 49–120; 먼저 도달하는 예산에서 중단

변경되지 않은 페이지는 요청 비용이 들지 않습니다. 변경된 페이지 목록은 모든 페이지의 스탬프와 제목을 담고 있으며, 둘 다 일치하는 저장된 사본은 다시 가져오지 않습니다. 따라서 실행 비용은 지난 시간에 편집된 페이지가 아니라 실제로 변경된 페이지를 따르며, 실행 보고서의 pagesSkipped는 그렇게 건너뛴 횟수입니다.

두 중첩 창의 너비가 다르며, 이는 의도적입니다. WATERMARK_OVERLAP_MS는 1시간이며 섹션의 목록이 반환하는 페이지를 결정합니다. 그것이 표면화하는 것은 요청 비용이 들지 않으므로 여유를 유지합니다. SECTION_SCAN_OVERLAP_MS는 15분이며 한 번 편집된 섹션이 계속 방문되는 기간을 결정합니다. 실행당 목록 요청 한 번 — 1시간은 마지막으로 줄일 가치가 있는 항목이었습니다. 둘 다 Graph의 전파 지연과 Graph 시계와 이 서비스 시계 사이의 간격에 대한 여유입니다. 작동 증명 아래의 두 로그 쿼리가 15분이 올바른지 말해줍니다.

MIRROR_SYNC_REQUEST_BUDGET(기본값 120)은 하드 스톱입니다. 이에 도달한 실행은 수행한 것을 커밋하고, "done": false와 함께 200으로 응답하며, 다음 일정에서 재개됩니다. 이것이 첫 백필을 가능하게 하는 것입니다.

두 중지 중 어느 것이 먼저 적용되는지는 다양하므로, 관찰된 평균이 아닌 요청 예산에 맞춰 주기를 조정하세요. 2026-08-20에 6회 실행에서 측정: 49, 57, 60, 63, 120, 120 요청. 처음 네 번은 예산이 남은 상태에서 240초 클록으로 종료되었습니다 — 페이지는 Graph 가져오기, resvg 잉크 렌더링, 두 번의 blob 쓰기가 들며, 1-CPU 인스턴스에서 클록을 채우는 것은 렌더링입니다. 마지막 두 번은 페이지 렌더링이 더 저렴한 섹션에서 클록 안에 전체 예산을 소진했습니다.

주기

예산 120에서 시간당 요청

*/20

최대 360

한도를 초과할 수 없는 가장 빠른 주기

*/15

최대 480

실행이 클록에 묶여 있는 동안은 한도 미만, 그렇지 않으면 초과

*/10

최대 720

실행이 요청에 묶일 때마다 한도 초과

*/5

최대 1440

2026-08-20 이 계정 기준 측정: 45분 내 429초

시간당 400을 넘으면 Graph는 OData 코드 10007로 429를 응답합니다. 거부된 요청은 작업을 수행하지 않으면서 시도를 소비하고, 섹션의 워터마크는 진행되지 않으며, 패널티는 짧은 백오프보다 오래 지속됩니다 — 3분에 걸친 5회 재시도가 한 번의 버스트 후 모두 거부되었으며, docs/graph.md에 기록되어 있습니다. 따라서 한도 초과는 백필을 더 일찍 끝내지 못합니다. MIRROR_SYNC_REQUEST_BUDGET을 절반으로 줄이면 동일한 시간당 지출로 간격이 절반이 되며, 실행당 트리 읽기 한 번이 추가됩니다.

이 계정의 백필 크기는 대략 섹션당 요청 한 번에 페이지당 한 번입니다. 202개 섹션에 각각 약 24페이지 — 목록이 완료된 처음 네 개 섹션에서 추정한 것이지 측정된 것이 아님 — 는 대략 4,800페이지와 5,000요청이므로, 어떤 주기든 최소 약 12.5시간의 슬라이스가 필요합니다.

MIRROR_READ_ENABLED=false에서는 지속적인 따라잡기가 전혀 없습니다. 이것이 현재 배포된 설정이므로, 백필 작업과 야간 스윕만이 미러에 접근합니다. 읽기를 켜는 것이 위의 읽기 중심 설계를 실제로 만들며, 새로고침당 최대 12요청이 30초에 1회를 넘지 않는 비율로 추가됩니다 — 지속적으로 바쁜 대화에서 시간당 약 120요청, 미러가 뒤처져 있는 동안 최대 144요청. 백필 작업이 사라지면 이 항목은 야간 스윕과 함께 400 아래에 들어갑니다.

설정

# 1. The secret. Never printed; the commands below read it from disk.
umask 077
openssl rand -hex 32 > sync.secret

gh secret set MIRROR_SYNC_SECRET < sync.secret
gh workflow run deploy.yml --ref main

# 2. The backfill job. Temporary — delete it when sync/state.backfillComplete is true.
#    --attempt-deadline sits just above Cloud Run's 300s request timeout, so the
#    platform's cut is the one that happens and the scheduler records it.
gcloud scheduler jobs create http onenote-mcp-sync \
  --project="$GCP_PROJECT" --location="$GCP_REGION" \
  --schedule="*/20 * * * *" --time-zone=UTC \
  --uri="$MCP_PUBLIC_URL/sync" --http-method=POST \
  --headers="X-Sync-Secret=$(cat sync.secret)" \
  --attempt-deadline=330s --max-retry-attempts=2

# 3. The nightly deletion sweep. This one is permanent: it is the only thing that
#    notices a page deleted out of an active notebook.
gcloud scheduler jobs create http onenote-mcp-sync-sweep-full \
  --project="$GCP_PROJECT" --location="$GCP_REGION" \
  --schedule="0 0 * * *" --time-zone=America/New_York \
  --uri="$MCP_PUBLIC_URL/sync/sweep/full" --http-method=POST \
  --headers="X-Sync-Secret=$(cat sync.secret)" \
  --attempt-deadline=330s --max-retry-attempts=1

rm sync.secret

# 4. When the backfill finishes.
gcloud scheduler jobs delete onenote-mcp-sync \
  --project="$GCP_PROJECT" --location="$GCP_REGION"

배포된 계정의 백필 작업은 */15에 있습니다. 실행이 클록에 묶여 있는 동안은 한도 미만 — 2026-08-20에 실행당 61–81요청으로 측정 — 이며, 다시 전체 120-요청 예산을 소비하기 시작하면 초과합니다. */20은 어느 쪽이든 초과할 수 없는 주기입니다. MIRROR_SYNC_REQUEST_BUDGET을 100으로 낮추면 */15도 안전해집니다.

재시도는 둘 다 안전합니다: 모든 모드는 멱등적이며, 예산 소진 실행 후의 재시도는 백필 중에 적극적으로 유용합니다. 두 작업이 모두 존재하는 동안 실행 임대는 중첩을 Graph 예산의 이중 지출이 아닌 409로 만듭니다.

/sync/sweep/all은 작업이 없습니다. activeNotebookIds를 편집한 후, 또는 노트북이 이 서버 외부에서 재구성되어 미러가 OneNote가 실제로 보유한 것과 대조가 필요할 때 수동으로 실행하세요.

미러링할 항목 선택

선택은 수동으로 편집하는 Firestore 문서 하나, MIRROR_ROOT_DOC, 기본값 onenoteMirror/default입니다. 서비스가 절대 쓰지 않는 유일한 문서입니다.

필드

유형

의미

notebookIds

문자열 배열

페이지가 미러링되는 노트북 ID

activeNotebookIds

문자열 배열, 선택 사항

그중 여전히 편집 중인 것. 없거나 null이면 전부를 의미합니다.

note

문자열

사용자를 위한 자유 텍스트. 절대 읽지 않음.

ID는 list_notebooks에서 가져옵니다. 구조 — 계정의 모든 노트북과 섹션 이름 — 는 선택과 무관하게 미러링됩니다. 트리 읽기가 동일한 요청 하나로 전부를 반환하고, 부분 구조는 list_notebooks와 범위 없는 search_pages가 자신 있게 그리고 부분적으로 응답하게 만들기 때문입니다. 페이지 콘텐츠만 선택을 따릅니다.

activeNotebookIds는 지속적인 작업을 좁히고 그 외에는 아무것도 하지 않습니다. 비활성 노트북의 페이지, 콘텐츠, 잉크는 활성 노트북과 정확히 동일하게 저장되며, 이 서버를 통한 쓰기는 여전히 도달하고 미러도 여전히 업데이트하며, 읽기도 여전히 응답합니다. 변경되는 것은 백필이 완료된 후 증분 패스와 스윕이 다시 확인하지 않는다는 점입니다. 따라서 OneNote 클라이언트에서 비활성 노트북에 대한 편집은 노트북이 다시 활성화되거나 /sync/sweep/all이 수동으로 실행될 때까지 미러에 도달하지 않습니다.

노트북을 activeNotebookIds로 다시 이동하는 것은 안전하며, 해당 노트북만 비용이 듭니다: 다음 실행이 저장된 목록을 비교하여 활성화된 ID 하나를 찾고, 해당 노트북의 섹션을 각 섹션의 자체 워터마크에 대해 다시 목록화합니다. 다른 모든 노트북은 워터마크를 유지하고 목록화되지 않습니다. 실제로 변경된 페이지만 가져옵니다. notebookIds에 노트북을 추가하는 것도 동일하게 동작합니다.

notebookIds의 ID가 어떤 노트북과도 일치하지 않으면 모든 실행 보고서에서 unknownNotebookIds로 보고됩니다. activeNotebookIds의 ID가 미러링된 노트북을 지칭하지 않으면 — 오타이거나 notebookIds에 추가된 적 없는 실제 노트북 — unknownActiveNotebookIds로 보고되고 mirror-selection-unknown-active로 로깅됩니다. 편집 후 둘 다 확인하세요: ID는 불투명한 문자열이며, 오타는 조용히 동기화되지 않는 노트북이 됩니다.

작동 증명

# While the backfill job exists. Once it is deleted, the sweep job is the one to force,
# and a covered read is what exercises the incremental path.
gcloud scheduler jobs run onenote-mcp-sync --project="$GCP_PROJECT" --location="$GCP_REGION"

# The report the run returned, in the service log.
gcloud logging read \
  'resource.type="cloud_run_revision" AND jsonPayload.event="sync-completed"' \
  --project="$GCP_PROJECT" --limit=3 --freshness=30m --format='value(jsonPayload)'

200은 그 자체로 증거가 아닙니다. 증거가 되는 세 가지:

  • Firestore에서 sections/{id}.pagesSyncedThrough가 이동함. 200으로 응답하고 워터마크를 진행하지 않은 실행은 아무것도 하지 않은 것입니다.

  • 버킷에 잉크가 있는 페이지당 객체가 하나 있고, 바이트 수가 페이지 문서의 ink.bytes와 일치합니다.

  • sectionsSkippedInactive가 예상한 숫자입니다. 모든 미러링된 섹션을 포함하는 수는 활성 집합이 비어 있거나 잘못 입력되었음을 의미합니다. 같은 보고서의 unknownActiveNotebookIds가 어느 쪽인지 알려줍니다.

두 가지 로그 기반 숫자가 중첩 창의 너비가 올바른지 말해줍니다. 둘 다 긴급하지 않습니다. 서비스가 일주일 동안 실행된 후 읽으세요.

# How far back the page-listing overlap actually reached to catch a page that had really
# changed. The largest ageMs over a day is the narrowest WATERMARK_OVERLAP_MS that would
# still have caught every one of them; approaching 3600000 means that hour is too narrow.
# Tens of minutes is ordinary. Nothing logged at all means the overlap has never caught
# anything.
#
# It says nothing about SECTION_SCAN_OVERLAP_MS. A section that window declines is never
# listed, so it produces no line here at all -- that failure is silence, not a large
# number, and nothing observes it. The nightly /sync/sweep/full is the backstop for it.
gcloud logging read \
  'resource.type="cloud_run_revision" AND jsonPayload.event="sync-overlap-save"' \
  --project="$GCP_PROJECT" --limit=50 --freshness=1d --format='value(jsonPayload.ageMs)'

# How far this service's clock is from Graph's, in milliseconds, signed. Both overlap
# windows and the settle guard are sized against this being seconds rather than minutes.
# Nothing logged means it stayed under the threshold, which is the expected reading.
gcloud logging read \
  'resource.type="cloud_run_revision" AND jsonPayload.event="graph-clock-skew"' \
  --project="$GCP_PROJECT" --limit=50 --freshness=1d --format='value(jsonPayload.skewMs)'

작업을 다시 실행하고 연속 보고서에서 done을 읽어 백필을 모니터링하세요: pagesUpdated가 증가하는 false, 그다음 true. sync/state.backfillComplete도 같은 것을 말하며, onenote-mcp-sync 작업을 삭제할 신호입니다.

실행 중 요청 로그에서 429를 주시하세요. 백필 작업이 시간당 400건 이상의 요청으로 실행되는 동안 429는 그 선택의 예상된 결과이며, 해결책은 더 긴 주기입니다. 그 외의 시점에 429가 나타나면 무언가가 공유 요청 게이트를 우회하고 있다는 뜻이며, 그 실패가 전체 계정이 제한되는 원인입니다.

상태

의미

조치

200, outcome: complete

실행이 작업을 완료함

없음

200, outcome: budget-exhausted

요청 또는 시간 예산에서 중단됨

없음; 다음 실행이 재개함

401

시크릿이 없거나 잘못됨

작업을 수정하세요; 경로는 아무 작업도 하지 않음

404

서비스에 MIRROR_SYNC_SECRET이 설정되지 않음

설정하고 재배포하세요

409

다른 실행이 임대를 보유 중

없음; 15분 후 만료됨

504

실행이 Cloud Run의 300초 요청 시간 초과를 넘김

없음; 아래 참조

503, reason: silent-failed

Microsoft 새로고침 토큰이 사라짐

npm run bootstrap 다시 실행

503, reason: cache-unavailable

Firestore가 응답하지 않음

재시도; 자격 증명은 정상임

읽기

MIRROR_READ_ENABLED가 스위치입니다. false(기본값)로 설정하면 모든 도구가 미러가 존재하기 전과 똑같이 Microsoft Graph에서 응답합니다. true로 설정하면 7개의 도구가 로컬 복사본을 사용합니다: list_notebooks, list_sections, list_pages, list_pages_by_name, find_page_by_name, get_page_content, search_pages.

각 읽기는 다음 순서로 세 단계로 진행됩니다.

  1. 새로고침. 도구가 자체적으로 증분 동기화를 실행합니다 — POST /sync가 실행하는 것과 동일한 runIncremental이지만 더 작은 예산으로: OneNote 요청 12개와 15초. 백필 작업이 삭제된 후에는 이것이 증분 패스를 실행하는 유일한 것입니다.

  2. 로컬 복사본 읽기. 적중은 OneNote 요청을 소모하지 않습니다. 실패는 이전과 똑같이 Microsoft Graph로 폴스루됩니다.

  3. 출처 보고. source는 답변이 OneNote가 보유한 것과 같을 때 onenote, 일부가 운영자가 고정한 노트북에서 온 경우 best-available, 뒤처질 수 있을 때 mirror입니다.

source 규칙 전체. "확인됨"은 1단계의 새로고침이 완료되었거나, 답변이 단일 페이지이고 어떤 쓰기도 오래된 것으로 표시하지 않았음을 의미합니다:

상황

source

읽기가 Microsoft Graph로 폴스루됨

onenote

활성 노트북의 로컬 적중, 확인됨

onenote

활성 노트북의 로컬 적중, 확인되지 않음

mirror

비활성 노트북의 로컬 적중, 확인 여부와 무관

best-available

둘 다 포함하는 범위 없는 검색, 확인됨

best-available

둘 다 포함하는 범위 없는 검색, 확인되지 않음

mirror

새로고침이 "완료"되었다는 것은 노트북 트리를 읽고, 필요한 모든 섹션을 방문하고, 필요한 모든 페이지를 가져오고, 두 예산 중 어느 것도 소진하지 않았음을 의미합니다. 그 외의 모든 것 — 요청 또는 시간 예산 소진, 트리 읽기 500 오류, 페이지 가져오기 실패, 스케줄러가 동기화 임대 보유, Firestore 응답 없음 — 은 mirror이며, 답변에는 복사본이 생성된 시점을 나타내는 mirroredAt이 포함됩니다. get_page_content는 처음 세 행의 예외입니다: 페이지 문서는 자체 오래됨 플래그를 가지며 모든 쓰기는 OneNote에 닿기 전에 해당 페이지를 오래된 것으로 표시하므로, 그곳의 적중은 어떤 것도 대체하지 않은 페이지입니다.

best-available은 저하된 답변이 아니며 새로고침이 실패해도 mirror가 되지 않습니다: 그 새로고침은 비활성 노트북을 확인하지 않았을 것이므로, 그것을 보고하면 답변을 변경할 수 없었던 실패를 설명하는 것입니다. 그것이 말하는 것은 복사본이 이 서버를 통한 쓰기 또는 /sync/sweep/all에 의해서만 다시 확인된다는 것입니다. list_notebooks는 노트북별로 pagesActive를 보고하고, 범위 없는 search_pages는 notebooksSearched 옆에 inactiveNotebooks를 보고하므로 모델이 건너뛴 크기를 알 수 있습니다. list_notebooks와 list_sections는 best-available을 보고하지 않습니다 — 구조는 활성 세트가 무엇이든 모든 실행에서 전체 계정에 대해 저장됩니다.

새로고침은 모든 호출에서 실행되지 않습니다. 완료된 새로고침은 30초 동안 유지되고, 완료되지 않은 새로고침은 5분 동안 유지되므로, 도구 호출의 폭주는 30번이 아닌 한 번의 새로고침 비용을 지불합니다. 최악의 경우 조용한 계정에서 지속적으로 바쁜 대화로 인해 시간당 약 120개의 추가 OneNote 요청이 발생하며, 400의 한도에 대비됩니다 — 그리고 조용한 계정에서의 새로고침은 단일 요청, 즉 노트북 트리입니다.

라이브 읽기를 강제하는 호출별 인수는 없습니다. 예전에는 있었지만(useLiveData) 사라졌습니다. 먼저 새로고침한 다음 새로고침이 완료되었는지 말하는 읽기는 호출 모델이 추론해야 하는 스키마 필드 없이 같은 질문에 답하기 때문입니다.

끄는 것은 완전한 롤백입니다: 변수를 false로 설정하고 재배포하면 도구는 미러 이전 동작과 바이트 단위로 동일합니다. 새로고침이 실행되지 않고, 도구 호출이 동기화에 요청을 소모하지 않으며, 모든 답변이 source: "onenote"라고 말합니다. 데이터 마이그레이션은 없고, 미러는 백그라운드에서 계속 채워집니다.

쓰기는 해당 페이지를 즉시 재동기화합니다. 다섯 개의 쓰기 도구 모두 성공적인 쓰기 후 OneNote에서 페이지를 다시 읽고 저장하므로, append_to_page 직후의 get_page_content는 방금 추가된 텍스트로 로컬 복사본에서 답변합니다. 이는 쓰기당 OneNote 요청 하나를 추가로 소모합니다. 재동기화가 실패하면 페이지가 대신 오래된 것으로 표시되어 다음 읽기를 OneNote로 보냅니다 — 더 느리지만 결코 틀리지 않습니다 — 그리고 이후 읽기 앞의 새로고침이 어느 쪽이든 복구합니다. 실패한 재동기화는 쓰기를 실패시키지 않습니다.

켜기 전에 확인할 두 가지:

  • 네 개의 복합 인덱스가 모두 READY로 표시됩니다. 아직 CREATING인 인덱스에 대한 쿼리는 FAILED_PRECONDITION으로 실패합니다.

  • sync/state.backfillComplete가 true입니다. 더 일찍 켜는 것은 안전합니다 — 미러되지 않은 페이지는 실패하여 Graph로 이동합니다 — 하지만 대부분의 읽기가 폴스루되어 미러가 없는 것보다 느리고 백필이 필요로 하는 예산을 소모합니다.

알아둘 만한 한 가지 동작 변경은 search_pages입니다. Graph에서 답변할 때 섹션을 탐색하고 60개 섹션 또는 25초에서 중지하므로 sectionsSearched, sectionsFound, stoppedEarly를 보고합니다. 미러에서 답변할 때는 탐색도 경계도 없습니다 — 하지만 페이지 콘텐츠는 선택한 노트북에 대해서만 보유되므로 notebooksInAccount 대신 notebooksSearched를 보고하고, 다를 때 메모에 그렇게 명시합니다. "일치 없음"을 읽는 모델은 어떤 것을 얻었는지 알아야 합니다. 선택 외부의 노트북을 검색하려면 sectionId를 전달하세요: 로컬 복사본에서 실패하고 OneNote에서 해당 섹션 하나를 탐색합니다.

실행이 초과될 때

MIRROR_SYNC_REQUEST_BUDGET와 240초 벽시계 예산은 모두 Cloud Run의 300초 요청 시간 초과 훨씬 전에 실행을 중지하므로, 일반적인 답변은 "done": false가 포함된 200입니다. 504는 무언가가 예상보다 오래 걸렸음을 의미합니다 — 거의 항상 긴 Retry-After가 있는 OneNote의 429입니다.

그때 무슨 일이 일어나는지 알아둘 가치가 있습니다. 서비스는 CPU 제한과 최소 인스턴스 수 없이 배포되므로, 잘린 요청은 두 가지 운명 중 하나를 만나며 Cloud Run은 어느 것도 보장하지 않습니다: 인스턴스가 여전히 따뜻하면 프로세스는 일시 중단되고 다음 요청이 도착할 때 재개되며, 0으로 축소되었다면 작업은 단순히 사라집니다. 어느 쪽이든 실행은 완료되지 않고, 임대는 해제되지 않으며, 재시도는 임대가 15분 후 만료될 때까지 409로 응답합니다. 다음 실행 — 백필 작업, 야간 스윕, 또는 다음 포함된 읽기 앞의 새로고침 — 은 정상적으로 진행됩니다.

어느 경우에도 손실되는 것은 없습니다. 워터마크는 섹션별로 진행되며 해당 섹션의 페이지가 저장된 후에만 진행되므로, 잘린 실행은 커밋한 모든 것을 유지하고 다음 실행은 그 지점에서 재개합니다. 실행 사이에 인스턴스가 단순히 사라지는 경우에도 동일합니다: 미러, Microsoft 새로고침 토큰, 이 서버가 발급하는 모든 토큰은 메모리에 보관되지 않고 저장되거나 서명되므로, 인스턴스는 언제든지 사라질 수 있으며 토큰 왕복 한 번 이상의 비용이 들지 않습니다.

반복적인 504가 보이면 확인할 한 가지는 요청 로그의 429입니다. 지속적인 제한이 시간 초과에 도달하는 유일한 현실적인 방법이며, 해결책은 더 긴 Cloud Run 시간 초과가 아니라 더 작은 MIRROR_SYNC_REQUEST_BUDGET 또는 더 긴 간격입니다.

잘못된 미러 복구

MIRROR_ROOT_DOC의 notebooks, sectionGroups, sections, pages, pageContent, tombstones 하위 컬렉션을 삭제하고, sync/state에서 structureHash와 sectionsScannedThrough를 지우세요. 그런 다음 설정에서 onenote-mcp-sync 작업을 다시 만들고 backfillComplete가 다시 true가 될 때까지 두세요: 읽기 앞의 인라인 새로고침은 12-요청 예산을 가지며 처음부터 미러를 재구축하지 않습니다. 선택 문서와 토큰 캐시는 이에 영향을 받지 않으며, Graph 쓰기는 포함되지 않습니다 — 미러는 복사본이고 Microsoft Graph가 진실의 원천으로 남습니다.

알림

두 가지 실패는 로그 기반 메트릭 없이는 보이지 않습니다. 둘 다 Claude 대화 내부의 메시지 또는 아무도 읽지 않는 줄로만 나타나기 때문입니다:

이벤트

의미

graph-auth-failure with retryable: "false"

Microsoft 권한이 만료되었습니다. 누군가 npm run bootstrap을 실행해야 합니다.

token-cache-write-refused

MSAL이 자격 증명이 없는 캐시를 전달했습니다. 저장된 복사본은 살아남았습니다. 무언가 잘못되었습니다.

gcloud logging metrics create onenote_mcp_auth_failure \
  --description="Microsoft Graph credential failures needing an operator" \
  --log-filter='resource.type="cloud_run_revision"
    resource.labels.service_name="onenote-mcp"
    jsonPayload.event=("graph-auth-failure" OR "token-cache-write-refused")
    jsonPayload.retryable!="true"'

그런 다음 해당 메트릭이 0보다 큰 경우의 알림 정책. 동의 승인도 주시할 가치가 있습니다: POST /consent가 302로 응답하는 것은 커넥터를 추가할 때만 발생해야 하며, 요청 로그에 이미 포함되어 있습니다.

jsonPayload.event="request" jsonPayload.path="/consent" jsonPayload.status=302

부트스트랩

npm run bootstrap은 프로젝트에서 유일한 대화형 Microsoft 로그인이며, Cloud Run이 아닌 사용자 머신에서 실행됩니다. 장치 코드 흐름으로 로그인하고 결과 MSAL 캐시를 서버가 읽는 Firestore 문서에 씁니다.

gcloud auth application-default login

ONENOTE_CLIENT_ID=00000000-0000-0000-0000-000000000000 \
ONENOTE_AUTHORITY=https://login.microsoftonline.com/common \
GOOGLE_CLOUD_PROJECT=your-project \
FIRESTORE_CACHE_DOC=tokencache/msal \
npm run bootstrap

Microsoft의 장치 코드 메시지를 출력하고, 브라우저에서 승인할 때까지 기다린 다음, 노트북을 한 번 나열하고 토큰이 작동한다는 증거로 개수를 출력합니다. 마지막 줄은 Firestore 프로젝트와 작성된 문서, 계정의 홈 테넌트를 명명하므로 올바른 디렉터리에 로그인했는지 확인할 수 있습니다. 해당 출력에는 테넌트 ID가 포함됩니다. 이슈, 풀 리퀘스트, 워크플로 로그에 올리지 마세요.

GOOGLE_CLOUD_PROJECT와 FIRESTORE_CACHE_DOC는 여기서 필수입니다. 서버에서는 첫 번째가 추론되고 두 번째가 기본값이지만, CLI는 사용자 자신의 Application Default Credentials로 쓰므로 설정되지 않은 값은 gcloud 로그인이 가리키는 프로젝트에 실제 문서를 생성하고 여전히 성공 줄을 출력할 수 있습니다. MCP_OAUTH_* 값은 읽지 않으므로 이 실행은 Layer-1 클라이언트 시크릿을 사용자 머신에 넣지 않습니다.

서버 로그에 retryable: "false"가 있는 graph-auth-failure 이벤트가 나타날 때마다 다시 실행하세요. 새로고침 토큰은 사용할 때마다 회전되며 서비스가 약 90일 이상 유휴 상태로 있으면 만료됩니다. 자동 복구는 없습니다. 위의 keepalive 작업을 구성하는 것이 유휴 상태가 그렇게 되는 방법 중 하나가 되는 것을 막는 것입니다.

컨테이너

서비스는 linux/amd64를 실행하는 Cloud Run에 배포됩니다. 이미지는 @resvg/resvg-js 네이티브 바이너리가 일치하도록 해당 플랫폼용으로 명시적으로 빌드됩니다. 런타임 베이스는 Debian node:24-slim이며 Alpine이 되어서는 안 됩니다. resvg 프리빌드는 glibc 전용이기 때문입니다.

docker build --platform linux/amd64 -t onenote-mcp .

docker run --rm -p 8080:8080 \
  -e PORT=8080 \
  -e ONENOTE_CLIENT_ID=00000000-0000-0000-0000-000000000000 \
  -e ONENOTE_AUTHORITY=https://login.microsoftonline.com/common \
  -e MCP_OAUTH_CLIENT_ID=test-client \
  -e MCP_OAUTH_CLIENT_SECRET=test-secret \
  -e MCP_TOKEN_SIGNING_KEY=0123456789abcdef0123456789abcdef \
  -e MCP_PUBLIC_URL=https://onenote-mcp.example.run.app \
  onenote-mcp

curl -i localhost:8080/health     # 200, {"status":"ok",...}

/healthz는 동일한 응답을 하며 Cloud Run 자체 프로브가 사용하는 엔드포인트입니다. 외부에서 호출하지 마십시오. Google 프론트엔드가 https://<service>.run.app/healthz에 자체 404 페이지로 응답하고 요청이 컨테이너에 도달하지 않으므로, 외부 uptime 검사는 /health를 사용해야 합니다. 2026-08-19에 배포된 서비스를 기준으로 측정했을 때 — /health, /healthz2, 심지어 /Healthz도 모두 도달하며, 정확히 소문자 /healthz만 삼켜집니다.

이 값들은 시작 검증을 통과할 만큼만 형식을 갖춘 자리 표시자일 뿐이며, 어떤 것에도 인증하지 않습니다. Cloud Run에서 PORT는 플랫폼이 제공하고 나머지는 배포 워크플로에서 제공됩니다. 호스트 포트 8080이 이미 사용 중이면 다른 포트로 매핑하십시오: -p 8081:8080과 함께 -e PORT=8080을 사용합니다.

이미지를 테스트하려면:

RUN_DOCKER_TESTS=1 bash scripts/test/run.sh

이 명령은 이미지를 빌드하고, resvg glibc 바이너리가 프로덕션 전용 설치에서 살아남았는지와 dev 의존성이 함께 들어오지 않았는지 확인하며, 컨테이너 내부에서 SVG를 PNG로 렌더링하고, PORT에 지정된 포트에서 /healthz가 200으로 응답하는지 검증합니다. RUN_DOCKER_TESTS=1이 없으면 docker 스위트는 건너뛰고 나머지는 계속 실행됩니다.

배포

.github/workflows/deploy.yml은 main에 대한 모든 푸시와 workflow_dispatch에서 실행됩니다. 타입 검사, npm test 실행, 빌드, Artifact Registry에 커밋 sha로 태그된 컨테이너 이미지 빌드 및 푸시, 그리고 해당 이미지를 Cloud Run에 배포합니다. 타입 검사 또는 테스트 실패 시 이미지가 빌드되기 전에 실행이 중지됩니다.

GitHub에는 장기 자격 증명이 없습니다. 작업은 Workload Identity Federation을 통해 인증합니다: permissions: id-token: write는 GitHub OIDC 토큰을 요청할 수 있게 하고, google-github-actions/auth@v2는 이를 단기 Google 자격 증명으로 교환합니다. scripts/gcp-bootstrap.sh에 의해 서비스 계정 JSON 키가 생성되지 않으며 어디에도 필요하지 않습니다. 공급자는 repository 클레임이 이 저장소인 토큰만 수락합니다.

이미지는 ubuntu-latest에서 빌드되는데, 이는 linux/amd64 — Cloud Run이 실행하는 플랫폼이자 @resvg/resvg-js 프리빌드가 컴파일된 플랫폼입니다. 이것이 워크플로가 gcloud run deploy --source를 사용하는 대신 이미지를 직접 빌드하는 이유입니다. --source를 사용하면 Cloud Build를 활성화하고 그에 따른 역할을 부여해야 하기 때문입니다.

배포는 런타임 서비스 계정으로 --max-instances=1 및 --allow-unauthenticated로 실행되며, 이 계정은 Firestore 토큰 캐시에 대한 roles/datastore.user를 보유합니다. --allow-unauthenticated는 Claude가 서비스에 전혀 도달할 수 있게 하는 것이며, MCP 엔드포인트는 대신 bearer 토큰으로 닫혀 있습니다. MCP 엔드포인트의 Bearer 토큰을 참조하십시오.

저장소가 보유해야 하는 것

scripts/gcp-bootstrap.sh는 GCP 측을 프로비저닝하고 처음 여섯 개에 대한 gh variable set 명령을 출력합니다. 워크플로는 반쯤 구성된 상태로 배포하는 대신 무엇이 누락되었는지 이름을 지정하며 첫 단계에서 실패합니다.

이름

종류

값

GCP_PROJECT

variable

프로젝트 ID

GCP_REGION

variable

Cloud Run 리전

GAR_REGION

variable

Artifact Registry 리전

WIF_PROVIDER

variable

전체 워크로드 ID 공급자 리소스 이름

DEPLOY_SA

variable

배포 서비스 계정 이메일

RUNTIME_SA

variable

런타임 서비스 계정 이메일

ONENOTE_CLIENT_ID

variable

Azure 앱 등록 클라이언트 ID

ONENOTE_AUTHORITY

variable

Entra 기관 URL

MCP_OAUTH_CLIENT_ID

variable

레이어-1 OAuth 클라이언트 ID

MCP_PUBLIC_URL

variable

서비스의 공개 URL; 아래 참조

FIRESTORE_CACHE_DOC

variable

선택 사항; 기본값은 tokencache/msal

MCP_OAUTH_CLIENT_SECRET

secret

레이어-1 OAuth 클라이언트 시크릿

MCP_TOKEN_SIGNING_KEY

secret

액세스 토큰 서명 키, 최소 32자

MCP_KEEPALIVE_SECRET

secret

선택 사항; 설정하지 않으면 POST /keepalive가 마운트되지 않음

MIRROR_BUCKET

variable

선택 사항; 페이지 미러의 Cloud Storage 버킷. scripts/gcp-bootstrap.sh가 생성한 이름을 출력합니다

MIRROR_READ_ENABLED

variable

선택 사항; 기본값은 false. 읽기 도구가 미러를 새로 고치고 사용하게 하는 스위치

MIRROR_ROOT_DOC

variable

선택 사항; 기본값은 onenoteMirror/default

MIRROR_SYNC_REQUEST_BUDGET

variable

선택 사항; 기본값은 120

MIRROR_SYNC_SECRET

secret

선택 사항; 설정하지 않으면 POST /sync가 마운트되지 않음

이 중 자격 증명은 네 개뿐입니다. WIF 공급자 이름과 서비스 계정 이메일은 식별자일 뿐이며, 이 저장소의 OIDC ID를 제시할 수 없는 사람에게는 쓸모가 없으므로 변수로 취급됩니다.

배포는 env_vars_update_strategy: overwrite를 전달하므로 워크플로의 목록이 모든 리비전에서 서비스의 전체 환경입니다. 액션의 기본값은 merge인데, 이 경우 워크플로에서 제거된 변수가 이전 리비전에서 조용히 살아남을 수 있습니다. PORT와 GOOGLE_CLOUD_PROJECT는 의도적으로 목록에 없습니다. Cloud Run이 둘 다 제공하며, PORT를 입력으로 거부하기 때문입니다.

첫 배포 및 MCP_PUBLIC_URL

MCP_PUBLIC_URL은 OAuth 발급자이자 이 서버가 발급하는 모든 액세스 토큰의 대상(audience)이며, 첫 배포가 이루어지기 전에는 URL을 가질 서비스가 존재하지 않습니다. 워크플로는 세 단계로 이를 해결합니다: MCP_PUBLIC_URL 저장소 변수, 그다음 Cloud Run이 이미 서비스에 할당한 URL, 마지막으로 — 둘 다 없을 때만 — https://placeholder.invalid를 사용하고 배포 직후 실제 URL로 교체합니다. 따라서 첫 실행은 무인으로 작동하고 올바른 값이 제자리에 있는 상태로 완료됩니다. URL을 명명하는 경고를 남기며, 저장소 변수로 설정하십시오. 커스텀 도메인을 서비스 앞에 두었을 때 세 소스 중 유일하게 살아남는 것이기 때문입니다.

MCP_PUBLIC_URL을 변경하는 것 자체는 아무것도 무효화하지 않지만, 이미 발급된 모든 액세스 토큰은 이전 대상(audience)에 바인딩되어 거부됩니다. 그런 일이 발생하면 Claude가 인증 흐름을 다시 실행합니다.

롤백

이미지 태그는 커밋 sha이므로 이전 이미지가 여전히 Artifact Registry에 있습니다:

gcloud run services update-traffic onenote-mcp --region "$GCP_REGION" --to-revisions <revision>=100

workflow_dispatch로 이전 커밋에서 워크플로를 다시 실행하는 것도 작동하며, 배포된 환경을 해당 커밋의 워크플로 파일과 일치하게 유지하는 방법입니다.

구성

모든 값은 환경 변수에서 오며 시작 시 검증됩니다. 누락되거나 잘못된 변수는 한 번에 잘못된 모든 것을 나열하는 ConfigError를 생성하고, 프로세스는 스택 트레이스 없이 종료 코드 1로 종료됩니다.

변수

필수

기본값

용도

ONENOTE_CLIENT_ID

예

—

Azure 앱 등록 클라이언트 ID(퍼블릭 클라이언트)

ONENOTE_AUTHORITY

예

—

테넌트용 Entra ID 기관(authority) URL

MCP_OAUTH_CLIENT_ID

예

—

Claude가 제시하는 레이어 1 OAuth 클라이언트 ID

MCP_OAUTH_CLIENT_SECRET

예

—

레이어 1 OAuth 클라이언트 시크릿

MCP_TOKEN_SIGNING_KEY

예

—

발급된 액세스 토큰 서명에 사용되는 키(최소 32자)

MCP_PUBLIC_URL

예

—

서비스 자체의 공개 URL: https, 쿼리 없음, 프래그먼트 없음, 끝에 슬래시 없음

FIRESTORE_CACHE_DOC

서버: 아니요 · 부트스트랩: 예

tokencache/msal

MSAL 토큰 캐시를 보관하는 Firestore 문서 경로

GOOGLE_CLOUD_PROJECT

서버: 아니요 · 부트스트랩: 예

—

GCP 프로젝트, Cloud Run에서 자동으로 유추됨

PORT

아니요

8080

바인딩 포트. Cloud Run이 설정하며 서버는 절대 하드코딩하지 않음.

MCP_KEEPALIVE_SECRET

아니요

—

최소 32자. 설정하면 POST /keepalive가 마운트되고, 설정하지 않으면 해당 경로는 404를 반환. Keepalive 참조.

MIRROR_ROOT_DOC

아니요

onenoteMirror/default

수동 편집된 notebookIds 및 activeNotebookIds 목록을 보관하는 Firestore 문서. 그 하위 컬렉션이 미러. 문서 경로, 짝수 세그먼트 수, FIRESTORE_CACHE_DOC와 동일한 규칙.

MIRROR_SYNC_SECRET

아니요

—

최소 32자. 설정하면 POST /sync가 마운트되고, 설정하지 않으면 해당 경로는 404를 반환.

MIRROR_BUCKET

조건부

—

렌더링된 잉크 및 대형 페이지 HTML용 Cloud Storage 버킷. MIRROR_SYNC_SECRET 또는 MIRROR_READ_ENABLED가 설정되면 필수, 아래 참조.

MIRROR_READ_ENABLED

아니요

false

true 또는 false만 허용. true이면 읽기 도구가 미러를 새로고침하고 미러에서 응답하며, 미스 시 Graph로 폴백.

MIRROR_SYNC_REQUEST_BUDGET

아니요

120

한 번의 동기화 실행이 중단되고 더 많은 대기 요청을 보고하기 전에 사용할 수 있는 Graph 요청 수. 시간당 400 한도에 대해 10~350.

다섯 개의 MIRROR_* 변수는 Firestore 페이지 미러를 구성합니다. 모두 선택 사항이므로, 이 중 아무것도 설정하지 않고 배포된 서비스는 미러가 존재하기 전과 정확히 동일하게 동작합니다. 이것이 MIRROR_READ_ENABLED를 코드 변경이 아닌 완전한 롤백 스위치로 만드는 이유입니다.

MIRROR_BUCKET은 loadConfig의 유일한 교차 필드 규칙입니다. VarSpec은 변수별로 필수 여부만 지정할 수 있고 "다른 변수가 있으면 필수"라고는 말할 수 없지만, 버킷이 없으면 동기화는 렌더링된 잉크 PNG를 둘 곳이 없고 미러 읽기도 가져올 곳이 없습니다. 따라서 버킷 없이 MIRROR_SYNC_SECRET을 설정하거나 MIRROR_READ_ENABLED를 true로 설정하면, 백필(backfill) 도중 첫 객체 쓰기에서 수시간 후에 실패하는 대신 컨테이너 시작 시 MIRROR_BUCKET이 누락 목록에 포함된 채 실패합니다.

MIRROR_READ_ENABLED는 대소문자를 구분하지 않고 true와 false만 허용합니다. 의도적으로 관대하지 않습니다. 1, yes, on은 사람에게 모두 true로 읽히지만, 이를 허용하는 파서는 off와 0이 무엇을 의미하는지도 결정해야 하며, 그 시점에 ture 같은 오타가 false가 되어 미러를 꺼버리고 아무 말도 하지 않게 됩니다.

FIRESTORE_CACHE_DOC은 src/token-cache.ts의 MSAL 캐시 플러그인이 읽고 쓰는 문서를 지정합니다. 그 값은 문서 경로여야 하며, 즉 슬래시로 구분된 세그먼트 수가 짝수여야 합니다. loadConfig는 시작 시 컬렉션 경로를 거부합니다.

ONENOTE_CLIENT_ID와 ONENOTE_AUTHORITY는 src/graph-auth.ts가 Entra ID에 제시하는 Azure 앱 등록을 식별합니다. 퍼블릭 클라이언트이므로 의도적으로 레이어 2 클라이언트 시크릿이 없습니다. 그 아래의 MCP_OAUTH_* 값은 Claude와 이 서버 사이의 레이어 1에 속하며 관련이 없습니다.

MCP_PUBLIC_URL은 Claude가 이 서비스에 도달하는 URL입니다. OAuth 발급자, 토큰이 바인딩되는 resource 식별자, 보호된 리소스 메타데이터 문서의 URL이 모두 이 값에서 생성됩니다. Cloud Run에서는 프로세스에 어떤 URL로 도달하는지 알려주는 것이 없으며, Host 헤더에서 가져온 값은 호출자가 보낸 그대로일 것이므로 구성으로 지정합니다. 첫 배포가 URL을 생성한 후에만 채울 수 있습니다.

npm run bootstrap은 ONENOTE_CLIENT_ID, ONENOTE_AUTHORITY, FIRESTORE_CACHE_DOC, GOOGLE_CLOUD_PROJECT만 읽습니다. MCP_OAUTH_* 값은 읽지 않으며, 마지막 두 개는 기본값을 사용하지 않고 필수로 요구합니다. Bootstrap 참조.

저장소 위생

이 저장소는 공개입니다. 실제 페이지 콘텐츠, 렌더링된 잉크, Entra 테넌트 이름 또는 ID, Firestore 문서 내용을 커밋하지 마십시오. .gitignore는 output/ 및 토큰 캐시 파일 패턴을 제외합니다. project-spec.md의 "Repo hygiene" 섹션을 참조하십시오.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables interaction with Microsoft OneNote via the Microsoft Graph API, allowing users to list notebooks and retrieve page content. It supports both personal and organization notebooks with credential caching for efficient authentication.
    21 npm
    3
    MIT
  • A
    license
    D
    quality
    C
    maintenance
    Enables AI assistants to securely interact with Microsoft OneNote data through the Microsoft Graph API. It supports comprehensive management tasks including searching page content, creating and editing notes, and automating productivity workflows like daily note creation.
    20
    3
    MIT