onenote-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@onenote-mcplist my notebooks and their sections"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
onenote-mcp
An MCP server that exposes Microsoft OneNote through Microsoft Graph — notebook and section structure, page content, and handwriting rendered to an image the calling model can read.
project-spec.md is the authoritative design document. It covers
the ink reconstruction pipeline, the two independent OAuth layers, the Cloud Run
deployment model, and the Firestore-backed token cache. Read it before changing anything
here.
Requirements
Node >= 24. @google-cloud/firestore requires Node >= 22, and Node 24 is the current
Active LTS.
Related MCP server: OneNoteMCP
Quick start
npm ci
npm run build
npm testScripts
Script | What it does |
| Compile |
| Type-check without emitting |
| Run the server from source with |
| Run the compiled server from |
| Local device-code sign-in that seeds the Firestore token cache |
|
|
Tests live in test/ and mirror src/. They run directly against TypeScript source
using Node's native type stripping, so npm test does not need a build. That constrains
the source: no enum, no namespace, no constructor parameter properties, and type-only
imports must be written import type. The erasableSyntaxOnly and verbatimModuleSyntax
compiler options enforce this.
See CLAUDE.md for the directory layout and the conventions that go with
it.
Token cache
src/token-cache.ts implements MSAL's ICachePlugin against a single Firestore
document, whose path comes from FIRESTORE_CACHE_DOC. beforeCacheAccess reads the
document's cache field and hands the string to MSAL. afterCacheAccess writes the
serialized cache back inside a Firestore transaction, and only when MSAL reports that the
cache changed. A document that does not exist is read as an empty cache, which is the
state before npm run bootstrap has been run. Both entrypoints use this same plugin:
the bootstrap CLI writes the cache through it and the server reads through it, so there
is one serializer and no second format to keep in step.
That blob is the only copy of the refresh token, so two things protect it.
A write that would empty the document is refused. MSAL removes credentials from its
in-memory cache on some failures, and afterCacheAccess runs inside MSAL's finally
block, so a serialization that has lost the account can reach this code while the stored
one is still good. overwriteWouldEmptyCache stops it and logs
{"event":"token-cache-write-refused"}. The emptiness check reads no MSAL key name — a
cache is empty when it parses to an object whose every value is an empty container — so
it cannot invert when MSAL changes its format, and anything it does not recognise is
allowed through rather than blocked.
The blob each write replaces is kept in a previousCache field. One generation, not
a history: the cache is rewritten on every refresh, and the useful copy is always the
most recent good one. Recovering from a bad write is copying that field over cache in
the Firestore console, which is worth having because the alternative is a device-code
sign-in. Turn on point-in-time recovery for a second layer:
gcloud firestore databases update --enable-pitrA backend failure is not a credential failure. Firestore being unreachable, or a
revoked roles/datastore.user binding, raises TokenCacheUnavailableError rather than
surfacing as the error a dead refresh token produces. Writes are retried three times
before that. See the cache-unavailable row in the table below for why the distinction
is worth the code.
npm test covers only readCache, the function that decodes a document snapshot. The
two callbacks, the transaction, and createFirestoreTokenCachePlugin have no automated
test — they need a Firestore backend. Exercising them means the emulator, which needs
java on PATH and an install of its own:
sudo apt-get install google-cloud-cli-firestore-emulatorgcloud components install cloud-firestore-emulator does not install it on a
Debian-packaged Google Cloud CLI. The component manager is disabled in that build, and
gcloud prints the apt-get command above in its place.
Graph auth
src/graph-auth.ts turns the seeded token cache into a Microsoft Graph access token.
createGraphAuth builds one PublicClientApplication from ONENOTE_CLIENT_ID,
ONENOTE_AUTHORITY, and the Firestore cache plugin, and holds it for the life of the
process. getAccessToken() reads the cached account, calls acquireTokenSilent, and
returns the token. Requested scopes are Notes.Read and Notes.ReadWrite, fully
qualified.
The deployed server never signs in interactively. It has no way to prompt anyone, and
Graph's OneNote endpoints do not support app-only auth, so there is no fallback when the
stored refresh token dies — a human re-runs npm run bootstrap. Every failure is
therefore a GraphAuthError saying so, rather than a raw MSAL error that would reach the
caller as a bare 401 from Graph:
| What happened | What to do |
| The Firestore document is absent, or its |
|
| Firestore did not answer, or the runtime service account lost | Retry. Not a sign-in. |
| The cache was read but holds no signed-in account |
|
| The stored refresh token is expired or revoked, or the token endpoint returned nothing usable |
|
cache-unavailable is the row that earns its keep. Firestore is read and written inside
acquireTokenSilent, through the cache plugin, so a backend outage used to arrive as the
same rejection a dead refresh token produces — and that message tells the operator to go
to a browser and replace a credential that is working. GraphAuthError.retryable carries
the distinction and only that reason sets it.
Every one of these also writes one line to stderr:
{"event":"graph-auth-failure","reason":"silent-failed","documentPath":"tokencache/msal","retryable":"false"}That line is the point. A tool failure otherwise appears only inside a Claude conversation, so without it nothing tells the operator the connector has stopped working. See Alerting below.
The messages name the document path and the underlying MSAL error, and deliberately
carry no account identifier: username is the user's UPN and homeAccountId embeds the
tenant id, neither of which belongs in a log.
npm test covers the acquisition logic through a fake client. createGraphAuth itself
has no automated test: it needs a cache seeded by a real device-code sign-in, and no
credential that could seed one may be committed. Run npm run bootstrap and then the
server against the same document to exercise it. Its consumer is the Graph structure
client below; nothing wires either into createApp yet.
Graph structure
src/graph-structure.ts reads the OneNote tree: notebooks, section groups, sections, and
the page list inside one section. new GraphStructure(auth) takes anything with a
getAccessToken(), so the server passes it the GraphAuth above.
Method | Returns |
| Every notebook, by display name |
| Sections directly under a notebook or section group |
| Section groups directly under a notebook or section group |
| Both of the above, fetched together |
| Pages in one section, most recently modified first, at most |
| One notebook with every nested section group resolved |
| Every notebook, each with its tree resolved |
| Every notebook with its sections and one level of section group, in a single request |
| Sections anywhere in the account whose name contains that text, each with its parent notebook and section group |
| Pages in one section whose title matches, compared case-insensitively by Graph |
containerKind is notebooks or sectionGroups — the two Graph relationship names.
Both container kinds expose the same child relationships, which is why the list methods
take the kind instead of existing twice.
getExpandedTree() is the cheap one. It asks Graph to expand the relationships rather
than walking them:
GET /me/onenote/notebooks?$select=id,displayName
&$expand=sections($select=id,displayName),
sectionGroups($select=id,displayName;$expand=sections($select=id,displayName))Measured against a 54-notebook account: one request and 78 KB, against 195 requests for
getFullTree(), which matters because OneNote allows 400 requests an hour and 5
concurrent. The $select inside each expand clause is what takes the response from
441 KB to 78 KB, and the separator inside a clause carrying both $select and $expand
is a semicolon. What it does not reach is a section group nested inside a section group —
Graph caps $expand nesting at two levels — so findSectionsByName covers that case in
one request instead, by filtering the account-wide section list and expanding each
section's parents.
api-overview.md records what these endpoints accept, including the places the service
contradicts its own documentation.
Three things the traversal handles that a single Graph call does not:
Nesting. Section groups are the UI's "tab groups", and they contain further section groups.
getNotebookTreerecurses.Paging. Every list call follows
@odata.nextLinkuntil it stops appearing. Graph chooses its own page size and ignores a larger$top, so one response is never proof that a collection is complete.listPagesInSectionstops as soon astopitems are in hand, sotopis a result count rather than a page size.The account-wide page list is never called.
GET /me/onenote/pagesfails with error 20266, "maximum sections exceeded", on a notebook-per-year structure. Page listing is always scoped to/me/onenote/sections/{id}/pages, and a test scanssrc/for the account-wide path.
Failures are GraphRequestError for a non-2xx response — it carries status,
statusText, and the response body, because error 20266 is only distinguishable from
any other 400 by that text — and GraphResponseError for a 2xx whose body is not the
expected shape, a listing that will not terminate, or section groups nested past 20
levels. No message contains a notebook, section, or page name.
npm test drives all of it through a fake fetch keyed by exact URL. What that cannot
check is whether Graph accepts those URLs; the query strings come from the validated
recon script in Appendix A of project-spec.md and are confirmed only by running against
the real tenant.
Ink
Graph's normal page-content endpoint drops handwriting and leaves
<!-- InkNode is not supported --> behind, and Graph cannot export a page as an image or
a PDF. Handwriting is therefore rebuilt from raw stroke data:
GET /me/onenote/pages/{id}/content?includeInkML=true answers multipart/mixed, one
part the same HTML and another the InkML. The strokes become an SVG and then a PNG, which
goes to the calling model as an image for its own vision to read. No OCR service is
involved.
Module | What it does |
|
|
|
|
|
|
Four details decide whether this works at all, and all four come from the validated recon
script in Appendix A of project-spec.md:
Namespaces are stripped. Graph emits
inkml:ink,inkml:trace,inkml:traceFormat.fast-xml-parseris configured withremoveNSPrefix: trueand every lookup uses the bare name.Channel order comes from
<traceFormat>. This account's points are X, Y, F, where F is pen pressure. Reading the first two numbers of each point draws pressure as a coordinate.Coordinates are himetric.
px = himetric * 96 / 2540. That is the same coordinate space the page HTML positions typed content in, so ink and typed content could later be registered against each other by arithmetic.Traces are anywhere in the tree. A page can carry more than one
<ink>root, and<traceGroup>elements nest. All of them are collected.
A page with no ink renders to null. That is the normal answer for a typed page, not an
error. The failures that do raise are InkParseError for trace groups nested past 50
levels and InkRenderError for a document resvg rejects; neither message reproduces any
of the document, because stroke coordinates are the user's handwriting.
test/fixtures/*.inkml are hand-authored — a few strokes, X/Y/F channel order, himetric
units, one file with two <ink> roots and nested <traceGroup> elements. No captured
page dump may be committed: rendered ink is fully legible personal notes.
MCP endpoint
The server speaks MCP over stateless Streamable HTTP at POST /mcp. Every request
builds its own MCP server, answers, and tears it down; nothing survives to the next one.
There is no session id, and there is no SSE — GET /mcp and DELETE /mcp are answered
405, and a POST replies with a JSON body rather than opening a stream. An open stream
would hold a Cloud Run instance alive and bill for idle time.
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}Both Accept types are required by the Streamable HTTP spec even though this server
never streams. createTools in src/tools.ts is the registry — six browsing tools
(list_notebooks, list_sections, list_pages, search_pages, find_page_by_name,
list_pages_by_name), one reading tool (get_page_content), and five writing tools
(append_to_page, append_to_page_by_name, create_page, create_page_by_name,
update_page_title) — and src/mcp-server.ts is the
JSON-RPC surface around them.
A tool that throws comes back as a tool result with isError: true and a readable
message — an expired refresh token, a page that is gone, and a document resvg rejects are
all normal outcomes, not protocol faults. Only a call to a tool that was never registered
is a JSON-RPC error.
Every request writes one JSON log line: the HTTP verb, the path, the status, the
duration, the JSON-RPC method, and the tool name on a tools/call. Never the query
string, the headers, the arguments, or the result — see src/logging.ts.
/mcp is closed behind a bearer token — see Bearer tokens on the MCP
endpoint. The health endpoint stays open.
OAuth discovery
Claude has to find the authorization server before it can start a flow. src/oauth-router.ts
mounts the SDK's mcpAuthRouter at the application root — it builds its paths from the
issuer URL rather than from a mount point, so it cannot go behind a prefix — and serves
five routes, all of them unauthenticated by necessity:
Path | What it is |
| RFC 8414 authorization-server metadata |
| RFC 9728 protected-resource metadata |
| Authorization endpoint |
| Where the consent form posts back; the SDK's |
| Token endpoint |
curl -s localhost:8080/.well-known/oauth-authorization-server
curl -s localhost:8080/.well-known/oauth-protected-resource/mcpEverything in both documents is derived from MCP_PUBLIC_URL: it is the issuer, and the
resource identifier is it plus /mcp. MCP_PUBLIC_URL is rejected at startup if it
carries a trailing slash, so that every URL built by concatenating a path onto it is
well-formed; the issuer field then reports the URL-normalised form, which for an
origin-only value is the same string with a trailing slash added back. The protected-resource document is served only
at the path-suffixed URL — the bare /.well-known/oauth-protected-resource is a 404, and
so is /.well-known/openid-configuration. Claude probes the suffixed path first.
scopes_supported lists offline_access, which is what makes Claude ask for a refresh
token rather than re-consenting whenever an access token expires. There is no
registration_endpoint: the client id and secret are configured, so Dynamic Client
Registration has nothing to do. One client is registered, with three redirect URIs —
https://claude.ai/api/mcp/auth_callback for the hosted Claude surfaces, and
http://localhost/callback plus http://127.0.0.1/callback for Claude Code, whose port
is ignored per RFC 8252.
GET /authorize renders a consent page rather than redirecting: one Approve button
naming what is granted and the host the authorization code will be sent to. Approving
posts back to POST /consent, which mints a 60-second single-use code and redirects to
the client's callback. The whole authorization request crosses that page in one hidden
field signed with MCP_TOKEN_SIGNING_KEY, so a mid-consent instance replacement does not
break the flow and the form cannot be edited; a field that fails to verify is a 400 with
no redirect and no code minted.
Both consent responses carry Cache-Control: no-store, Referrer-Policy: no-referrer
— the form posts from the /authorize URL, which has state and the PKCE challenge in
its query string — X-Frame-Options: DENY, and a CSP of default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'. There is deliberately no
form-action: browsers have disagreed about whether it is checked against a redirect
target, and the consent POST answers with a redirect to claude.ai.
POST /consent has a rate limit of its own — 200 in 15 minutes — because it is mounted
ahead of the SDK's /authorize limiter on purpose and a rendered form stays postable for
ten minutes, so one trip through /authorize yields a field that can be replayed. The
limit sits above the 100-entry pending-code cap so that the store's own eviction, whose
behaviour is specified, is what a burst runs into first.
POST /token issues an access token good for one hour and a refresh token good for 30
days. Both are an HMAC-SHA256 over a compact payload under MCP_TOKEN_SIGNING_KEY and
nothing else — no store is consulted to verify one, which is what keeps a Cloud Run
revision replacement from forcing a reconnect. The payload carries the audience, which is
MCP_PUBLIC_URL plus /mcp, so a token is good for this MCP endpoint and no other. How
long those tokens live, and how to make a human approve more often, is two sections down.
Bearer tokens on the MCP endpoint
Every request to /mcp needs Authorization: Bearer <access token>. The SDK's
requireBearerAuth sits in front of the MCP router in createApp, and
verifyAccessToken in src/oauth-provider.ts is what it calls: the HMAC signature under
MCP_TOKEN_SIGNING_KEY, the token kind, the expiry, and the audience. A token that is
correctly signed and unexpired but carries another server's resource identifier is
refused — the SDK checks no audience of its own, so without that check a token minted for
a different MCP server by a server sharing this signing key would be accepted.
A request with no token, an expired token, or a token that fails any of those checks is
401 with a challenge header:
WWW-Authenticate: Bearer error="invalid_token", error_description="…",
resource_metadata="https://<MCP_PUBLIC_URL>/.well-known/oauth-protected-resource/mcp"The resource_metadata parameter is the part that matters: it is how Claude finds the
authorization server and starts the flow, so a 401 without it is a dead end rather than a
sign-in prompt. Claude refreshes reactively on a 401 and proactively a few minutes before
the stored expiry, so a 401 here is an ordinary event.
The token is read from the Authorization header and from nowhere else. ?access_token=
in the query string is not honoured — the MCP authorization spec forbids it, and
src/logging.ts leaves the query string out of the log line on the strength of that.
Which routes are open is the exempt list, and it is longer than "everything except
/mcp" because the whole authorization flow has to answer callers who hold no token yet:
/healthz and /health, both .well-known documents, /authorize, /consent, and
/token. A test
in test/server.test.ts enumerates the routes createApp actually registers and asserts
that every one not on that list answers 401 without a token, so a route added later is
closed unless someone opens it deliberately.
No scopes are required. offline_access, the one scope this server issues, is about
whether a refresh token is granted rather than about what a caller may do, and requiring
it would answer 403 for tokens that are otherwise good. If a scope check is ever added,
the 403 has to carry WWW-Authenticate: Bearer error="insufficient_scope" — which this
middleware does — because Claude treats any other 403 as terminal and prompts for
nothing.
Token lifetime and forcing revalidation
This server is built to run unattended. The default settings reflect that, and they trade away some ability to cut off a leaked credential. Read this before deploying it somewhere that matters, and change the numbers if the trade is wrong for you.
What the defaults do
Token | Lifetime | What renews it |
Access token | 1 hour | The refresh token, automatically |
Refresh token | 30 days | Every refresh mints a new one with a fresh 30 days |
Consent form | 10 minutes | Nothing; a stale form is refused and the flow restarts |
Claude refreshes on its own — proactively before the hour is up, and reactively on a 401. So a human clicks Approve when the connector is first added, and then only if the connector goes unused for 30 days. That is the sliding window: the 30 days bound how long the connection may sit idle, not how long it may live.
Why sliding, and what it costs
Every token this server issues is stateless. It is a signed payload and nothing more — no database row, no session record, nothing to look up when it comes back. That is what makes a Cloud Run revision replacement invisible: the new instance verifies a token the old instance issued, with no shared state between them. A token store would mean a reconnect on every deploy.
The cost is that nothing can be revoked individually. There is no revocation endpoint because there is nothing for it to delete. Specifically:
A refresh token that leaks grants access for up to 30 days, and each use extends its holder's access by another 30. There is no server-side record to invalidate, and no way to tell a stolen refresh token from the legitimate one — both are the same bytes signed by the same key.
Sliding the window is not rotation. When a refresh mints a new refresh token, the one it replaces keeps working until the expiry stamped inside it. Real rotation means marking the old token spent, which needs the store this design does not have.
An access token cannot be cut off inside its hour, for the same reason.
What is left is one blunt lever, and it works immediately: change
MCP_TOKEN_SIGNING_KEY and redeploy. Every access token, every refresh token and every
open consent page is invalidated at once, because all of them are verified against that
key. The next Claude request gets a 401 and the operator clicks Approve once. Rotating the
key on a schedule is a reasonable policy on its own.
The consent screen, for what it is worth, authenticates nobody — it has one button and no
password. What stands between a stranger and your notebooks is MCP_OAUTH_CLIENT_SECRET,
which POST /token requires, the redirect-URI allowlist that sends every authorization
code to claude.ai or to loopback, and PKCE binding the code to the client that started
the flow.
Making a human approve more often
Each of these is a source change, not a configuration value. That is deliberate: an operator who shortens the window is changing the security posture of the deployment, and that belongs in a commit somebody can read rather than in an environment variable somebody can forget.
Shorten the idle window. In 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 weekUnused for that long, the connector needs a click. Used regularly, it still never asks — the window keeps sliding forward. This bounds how long a leaked refresh token survives after the leak stops being used, and nothing more.
Stop the window sliding. This is what issue #22 originally specified, and it caps the
total life of a connection rather than its idle time: a human approves every 30 days no
matter how busy the connector is. One line in exchangeRefreshToken, in
src/oauth-provider.ts:
// 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);Refuse to issue refresh tokens at all. The strictest setting: a human approves every hour, because an expired access token has nothing to renew it. Two edits, both needed — the metadata switch alone does not stop the token being issued.
In
src/oauth-router.ts, emptySCOPES_SUPPORTED. Claude appendsoffline_accessto an authorization request only when the metadata advertises it, and that is the switch deciding whether it asks for a refresh token.In
src/oauth-provider.ts, drop therefresh_tokenfield from whatissueTokensreturns. It is issued today regardless of the scopes requested.
Expect this one to be visible in use: Claude sends the browser back to the consent screen mid-session when the hour runs out.
Shorten the access token. ACCESS_TOKEN_TTL_S in src/oauth-provider.ts narrows the
window in which a leaked access token works. It costs a token request per expiry and no
human involvement at all, so it is cheap — but it does nothing about a leaked refresh
token, which is the credential worth worrying about.
Keepalive
Microsoft's delegated refresh tokens lapse after roughly 90 days without use. The token
only slides forward when it is actually exchanged, and it is only exchanged when a tool
call arrives after the held access token has expired — so a connector nobody uses for
three months is a connector that needs a person at a browser running npm run bootstrap.
Nothing in the server can prevent that on its own, because nothing in the server runs
when nobody is calling it.
POST /keepalive is the fix. It calls acquireTokenSilent with forceRefresh: true,
which skips the held access token and exchanges the refresh token, so Entra issues a
replacement with a fresh window and src/token-cache.ts writes it to Firestore.
forceRefresh is the load-bearing part: without it MSAL answers from its own cache, no
request reaches Entra, and the window does not move.
Set MCP_KEEPALIVE_SECRET to at least 32 random characters and the route is mounted;
leave it unset and the path 404s. A scheduler presents the secret in the
X-Keepalive-Secret header, which is compared in constant time before any work is done.
It is a shared secret rather than a bearer token because a scheduler cannot run the OAuth
flow — it has no browser and nowhere to keep a refresh token — and it is its own variable
rather than the Layer-1 client secret so that a credential which can reach the whole MCP
surface is not also sitting in a scheduler job.
Setting it up on your own account
Four steps: generate a secret, get it onto the service, create the job, and check the job actually reaches the service. The whole thing is optional — skip it and the server works, up until the day nobody has called it in 90 days.
# 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 is required and is the scheduler's own region, which has nothing to do with
where the job's target is; using the Cloud Run region keeps one fewer value in your head.
scripts/gcp-bootstrap.sh enables cloudscheduler.googleapis.com too, so step 3's
enable is only needed on a project bootstrapped before that line existed.
Then prove it, because every failure mode here is silent — a wrong secret, a job pointed at the old URL, and a service deployed without the variable all look like a scheduler job sitting there enabled:
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)'A 200 means the token was exchanged. The thing that proves it was stored is the
updatedAt field of the Firestore document moving to the time of the run, which is the
only evidence the replacement refresh token survived the call. Measured on this
deployment on 2026-08-19: the forced run answered 200 and tokencache/msal advanced from
14:15:53Z to 14:30:54Z.
Weekly is ample against a 90-day window and leaves room for several missed runs. The job costs one token-endpoint round trip and one Firestore write.
To rotate the secret: set the new GitHub secret, deploy, then
gcloud scheduler jobs update http onenote-mcp-keepalive --update-headers=…. Any run
between the deploy and the job update answers 401 and does no work, which on a weekly
schedule is a window nothing lands in.
The header sits in the job definition, readable by anyone with roles/cloudscheduler.viewer
on the project, exactly as the env vars sit readable in the Cloud Run revision spec. That
is the same tradeoff taken in Deploy for not running Secret Manager.
Status | Meaning | What the scheduler should do |
200 | The refresh token was exchanged and the new one stored | Nothing |
401 | The secret is absent or wrong | Fix the job; the route did no work |
404 |
| Set it and redeploy |
503 with | Firestore was unreachable | Retry |
503 with | The grant is dead |
|
What this does not protect against: a conditional-access sign-in-frequency policy, a password change, an MFA reset, or an admin revoking the grant. Any of those kills the refresh token whatever the schedule says, and no code change avoids it. If the Entra tenant is yours, exempt this app registration from sign-in-frequency policies; if it is not, treat 90 days as an upper bound somebody else can shorten without telling you.
The keepalive route is also unrelated to the Layer-1 30-day window in Token lifetime below. That refresh token lives in Claude's connector store, and only Claude can present it or receive its replacement, so nothing running here can keep it alive. Losing it costs one click on the Approve button; losing the Microsoft one costs a device-code sign-in.
Alerting
Two failures are invisible without a log-based metric, because both show up only as a message inside a Claude conversation or as a line nobody is reading:
Event | Means |
| The Microsoft grant is dead. Someone has to run |
| MSAL handed over a cache with no credentials in it. The stored copy survived; something is wrong. |
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"'Then an alert policy on that metric being above zero. A consent approval is worth
watching too: POST /consent answering 302 should happen only when you add the
connector, and the request log already carries it.
jsonPayload.event="request" jsonPayload.path="/consent" jsonPayload.status=302Bootstrap
npm run bootstrap is the only interactive Microsoft sign-in in the project, and it runs
on your machine, not on Cloud Run. It signs in with the device-code flow and writes the
resulting MSAL cache to the Firestore document the server reads.
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 bootstrapIt prints Microsoft's device-code message, waits for you to approve in a browser, then lists your notebooks once and prints the count as proof the token works. The closing lines name the Firestore project and document written and the account's home tenant, so you can see you signed into the right directory. That output carries the tenant id; keep it off issues, pull requests, and workflow logs.
GOOGLE_CLOUD_PROJECT and FIRESTORE_CACHE_DOC are required here, unlike on the
server where the first is inferred and the second defaults. The CLI writes with your own
Application Default Credentials, so an unset value would seed a real document in
whichever project your gcloud login points at, and still print a success line. The
MCP_OAUTH_* values are not read, so running this never puts the Layer-1 client secret
on your machine.
Run it again whenever a graph-auth-failure event with retryable: "false" appears in
the server's logs. The refresh token is rotated on every use and dies if the service sits
idle past roughly 90 days; there is no automatic recovery. Configuring the keepalive job
above is what stops idleness being one of the ways to get there.
Container
The service deploys to Cloud Run, which runs linux/amd64. The image is built for that
platform explicitly so the @resvg/resvg-js native binary matches. The runtime base is
Debian node:24-slim and must not become Alpine — the resvg prebuild is glibc-only.
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 answers the same thing and is what Cloud Run's own probes use. Do not call it
from outside: Google's frontend answers https://<service>.run.app/healthz with its own
404 page and the request never reaches the container, so an external uptime check has to
use /health. Measured against the deployed service on 2026-08-19 — /health,
/healthz2 and even /Healthz all arrive, and only the exact lowercase /healthz is
swallowed.
Those values are placeholders that are only well-formed enough to pass startup
validation; they authenticate against nothing. On Cloud Run, PORT is supplied by the
platform and the rest come from the deploy workflow. If host port 8080 is already taken,
map a different one: -p 8081:8080 with -e PORT=8080.
To test the image:
RUN_DOCKER_TESTS=1 bash scripts/test/run.shThat builds the image, checks that the resvg glibc binary survived the production-only
install and that no dev dependencies came with it, renders an SVG to PNG inside the
container, and asserts /healthz answers 200 on the port given in PORT. Without
RUN_DOCKER_TESTS=1 the docker suite is skipped and the rest still runs.
Deploy
.github/workflows/deploy.yml runs on every push to main and on workflow_dispatch.
It type-checks, runs npm test, builds, builds and pushes the container image to
Artifact Registry tagged with the commit sha, and deploys that image to Cloud Run. A
failing type-check or test stops the run before an image is built.
There is no long-lived credential in GitHub. The job authenticates through Workload
Identity Federation: permissions: id-token: write lets it request a GitHub OIDC token,
and google-github-actions/auth@v2 exchanges that for short-lived Google credentials.
No service-account JSON key is created by scripts/gcp-bootstrap.sh or needed anywhere.
The provider only accepts tokens whose repository claim is this repository.
The image is built on ubuntu-latest, which is linux/amd64 — the platform Cloud Run
runs, and the one the @resvg/resvg-js prebuild is compiled for. That is why the
workflow builds the image itself rather than using gcloud run deploy --source, which
would also mean enabling Cloud Build and granting the roles that go with it.
The deploy runs at --max-instances=1 with --allow-unauthenticated, as the runtime
service account, which holds roles/datastore.user for the Firestore token cache.
--allow-unauthenticated is what lets Claude reach the service at all; the MCP endpoint
is closed by the bearer token instead. See Bearer tokens on the MCP endpoint.
What the repository has to hold
scripts/gcp-bootstrap.sh provisions the GCP side and prints the gh variable set
commands for the first six. The workflow fails on its first step, naming what is
missing, rather than deploying half-configured.
Name | Kind | Value |
| variable | Project ID |
| variable | Cloud Run region |
| variable | Artifact Registry region |
| variable | Full workload identity provider resource name |
| variable | Deploy service account email |
| variable | Runtime service account email |
| variable | Azure app registration client ID |
| variable | Entra authority URL |
| variable | Layer-1 OAuth client ID |
| variable | The service's public URL; see below |
| variable | Optional; defaults to |
| secret | Layer-1 OAuth client secret |
| secret | Access-token signing key, at least 32 characters |
| secret | Optional; unset means |
Only three of these are credentials. The WIF provider name and the service account emails are identifiers, useless to anyone who cannot present this repository's OIDC identity, so they are variables rather than secrets.
The deploy passes env_vars_update_strategy: overwrite, so the list in the workflow is
the service's entire environment on every revision. The action's default is merge,
under which a variable removed from the workflow would silently survive from the previous
revision. PORT and GOOGLE_CLOUD_PROJECT are deliberately not in the list: Cloud Run
supplies both, and it rejects PORT as an input.
The first deploy, and MCP_PUBLIC_URL
MCP_PUBLIC_URL is the OAuth issuer and the audience of every access token this server
issues, and no service exists to have a URL until the first deploy has happened. The
workflow resolves it in three steps: the MCP_PUBLIC_URL repository variable, then the
URL Cloud Run has already assigned to the service, then — only when neither exists —
https://placeholder.invalid, which it replaces with the real URL immediately after the
deploy. So a first run works unattended and finishes with the correct value in place. It
leaves a warning naming the URL; set the repository variable to it, because that is the
only one of the three sources that survives putting a custom domain in front of the
service.
Changing MCP_PUBLIC_URL invalidates nothing by itself, but every access token already
issued is bound to the old audience and will be refused. Claude re-runs the authorization
flow when that happens.
Rolling back
The image tag is the commit sha, so an earlier image is still in Artifact Registry:
gcloud run services update-traffic onenote-mcp --region "$GCP_REGION" --to-revisions <revision>=100Re-running the workflow from an earlier commit with workflow_dispatch also works, and
is the one that keeps the deployed environment in step with that commit's workflow file.
Configuration
Every value comes from an environment variable, validated at startup. A missing or
malformed variable produces a ConfigError listing everything that is wrong at once,
and the process exits 1 without a stack trace.
Variable | Required | Default | Purpose |
| yes | — | Azure app registration client ID (public client) |
| yes | — | Entra ID authority URL for the tenant |
| yes | — | Layer-1 OAuth client ID that Claude presents |
| yes | — | Layer-1 OAuth client secret |
| yes | — | Key used to sign issued access tokens (min 32 chars) |
| yes | — | The service's own public URL: |
| server: no · bootstrap: yes |
| Firestore document path holding the MSAL token cache |
| server: no · bootstrap: yes | — | GCP project; inferred automatically on Cloud Run |
| no |
| Bind port. Cloud Run sets this; the server never hardcodes one. |
| no | — | At least 32 characters. Set it and |
FIRESTORE_CACHE_DOC names the document the MSAL cache plugin in src/token-cache.ts
reads and writes. Its value must be a document path, meaning an even number of
slash-separated segments; loadConfig rejects a collection path at startup.
ONENOTE_CLIENT_ID and ONENOTE_AUTHORITY identify the Azure app registration that
src/graph-auth.ts presents to Entra ID. It is a public client, so there is deliberately
no Layer-2 client secret; the MCP_OAUTH_* values below it belong to Layer 1, between
Claude and this server, and are unrelated.
MCP_PUBLIC_URL is the URL Claude reaches this service at. The OAuth issuer, the
resource identifier a token is bound to, and the URL of the protected-resource metadata
document are all built from it. Nothing on Cloud Run tells the process what URL it is
reached at, and a value taken from the Host header would be whatever the caller sent,
so it is configured. It can only be filled in after the first deploy has produced the
URL.
npm run bootstrap reads only ONENOTE_CLIENT_ID, ONENOTE_AUTHORITY,
FIRESTORE_CACHE_DOC, and GOOGLE_CLOUD_PROJECT — not the MCP_OAUTH_* values — and it
requires the last two rather than defaulting them. See Bootstrap.
Repository hygiene
This repository is public. Do not commit real page content, rendered ink, Entra tenant
names or IDs, or Firestore document contents. .gitignore excludes output/ and the
token-cache file patterns; see the "Repo hygiene" section of project-spec.md.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables AI language models to interact with Microsoft OneNote via a standardized interface, supporting notebook and page management through natural language.1527MIT
- AlicenseNot gradedqualityDmaintenanceEnables 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.153MIT
- AlicenseDqualityCmaintenanceEnables 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.202MIT
- AlicenseNot gradedqualityFmaintenanceEnables natural language access to Microsoft OneNote notebooks, sections, and pages for reading and listing content.45MIT
Related MCP Connectors
Microsoft OneNote (Microsoft 365) MCP Pack
MCP-native open-source Notion alternative: read & write pages, databases and kanban boards.
Access the Notra API for managing posts, brand identities, integrations, and schedules.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/dovrosenberg/onenote-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server