This file provides guidance to coding agents (Claude Code, Cursor, Codex, etc.) when working with this repository. It is the single source of truth; CLAUDE.md imports it via @AGENTS.md.
MCP Evernote is an MCP (Model Context Protocol) server that integrates Evernote with AI assistants. It exposes note, notebook, tag, search, resource, and sync/polling operations as MCP tools over stdio, translating each call into an Evernote SDK operation. It is designed to work with both Claude Code (OAuth provided via env vars) and Claude Desktop (standalone OAuth flow).
Core purpose:
- Translate MCP tool calls into Evernote API operations over stdio
- Manage notes/notebooks/tags with Markdown ↔ ENML conversion and local-file attachments
- Detect the runtime environment and resolve Evernote auth tokens accordingly
- Optionally poll Evernote for changes and notify a webhook
# Install dependencies
npm install
# Development with hot-reload (tsx watch on src/index.ts)
npm run dev
# Build TypeScript to dist/ (tsc, then chmod +x the bin entrypoints)
npm run build
# Start the built server (stdio mode)
npm start
# Standalone OAuth flow (Claude Desktop) — from source / from dist
npm run auth
npm run auth:prod
# Interactive setup wizard / install into Claude Code
npm run setup
npm run setup:claude
# Lint and format
npm run lint
npm run format
# Tests
npm test # full Jest suite
npm run test:unit # __tests__/unit
npm run test:integration # __tests__/integration
npm run test:e2e # __tests__/e2e
npm run test:coverage # with coverageAll scripts are defined in package.json "scripts". The build emits to dist/ and exposes two bins: mcp-evernote → dist/index.js and mcp-evernote-auth → dist/auth-standalone.js (package.json bin). Node >=20.16.0 <21 || >=22.3.0 is required (package.json engines) — this mirrors the disjoint range pdf-parse (and its pdfjs-dist transitive) declares, so Node 21.x and 22.0–22.2 are excluded. __tests__/unit/engine-range.test.ts fails if the two drift apart.
┌──────────────────────────────────────────────┐
│ MCP Client (Claude Code / Claude Desktop) │
│ - Calls MCP tools over stdio │
└─────────────┬────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ MCP Evernote Server (this TypeScript app) │
│ - src/index.ts: Server + stdio transport, │
│ ListTools/CallTool handlers │
│ - src/oauth.ts: env detection + token order │
│ - src/evernote-api.ts: Evernote SDK wrapper │
│ - src/markdown.ts: Markdown ↔ ENML │
└─────────────┬────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Evernote service (SDK / Thrift API + OAuth) │
│ - NoteStore / UserStore, sync chunks │
└──────────────────────────────────────────────┘
The server uses the classic MCP SDK pattern: a Server instance with
setRequestHandler(ListToolsRequestSchema, …) and
setRequestHandler(CallToolRequestSchema, …) over StdioServerTransport
(src/index.ts:301, src/index.ts:752, src/index.ts:759,
src/index.ts:1705). Tool arguments are validated with Zod via
validateToolArgs from src/tool-schemas.ts (src/index.ts:14).
src/
├── index.ts # MCP server entry: Server setup, tool definitions + CallTool handlers, polling wiring
├── evernote-api.ts # EvernoteAPI class — SDK wrapper (CRUD, search, resources, sync); thin ENML conversion wrappers
├── markdown.ts # Markdown ↔ ENML conversion (markdownToENML / enmlToMarkdown), GFM + local attachments
├── oauth.ts # EvernoteOAuth — environment detection + token resolution order
├── auth-standalone.ts # Standalone OAuth flow (Express /oauth/callback), persists .evernote-token.json
├── polling.ts # Sync-state polling: detects note/notebook/tag changes via sync chunks
├── webhook.ts # HMAC-SHA256 webhook signature compute/verify
├── path-security.ts # Allow-listed local file path validation for attachments
├── tool-schemas.ts # Zod schemas + validateToolArgs
├── types.ts # Shared TypeScript interfaces (EvernoteConfig, OAuthTokens, …)
└── evernote.d.ts # Ambient typings for the evernote SDK
__tests__/ unit/ · integration/ · e2e/ · mocks/ (Jest; see TEST_DOCUMENTATION.md)
dist/ build output — do not edit directly
scripts/ setup.js, install-to-claude.js, detect-environment.js, post-install.js
Markdown ↔ ENML lives in src/markdown.ts. markdownToENML and
enmlToMarkdown are defined and exported there (src/markdown.ts:59,
src/markdown.ts:119). EvernoteAPI only provides thin instance-method
wrappers — convertMarkdownToENML (src/evernote-api.ts:538) and
convertENMLToMarkdown (src/evernote-api.ts:543) — that delegate to those
functions (imported at src/evernote-api.ts:4). Do not add conversion logic
directly in evernote-api.ts.
The server defines 15 tools, all prefixed evernote_ (defined in the
tools array in src/index.ts). They cover notes (create_note,
get_note, update_note, delete_note, search_notes), notebooks
(list_notebooks, create_notebook, update_notebook), tags (list_tags,
create_tag, update_tag), resources/attachments (get_resource,
add_resource_to_note), and two action-dispatched admin tools —
polling({action}) and connection({action}).
Consolidated surface (was 27). Read/lifecycle operations are parameterized rather than multiplied into separate tools:
get_resource({guid, as})projects one attachment throughas: "text"(default; PDF/OCR extraction) |"binary"(base64) |"recognition"(OCR data) |"metadata"(filename/mime/size/hash/hasRecognition). Replaces the oldget_resource_text/get_resource_recognitiontools; a note's attachment list comes fromget_note'sresources[](so nolist_note_resources).list_notebooks/list_tagsreturn the full list, or one entity when passed anameorguid(absorbing the oldget_notebook/get_tag).update_notetakesreplacements[]for targeted find-and-replace (patch mode, mutually exclusive with the full-field inputs) — absorbing the oldpatch_note.polling({action: start|stop|poll|status})andconnection({action: status|user|reconnect|revoke})replace the four polling command tools and the fourhealth_check/get_user_info/reconnect/revoke_authtools.
Backward-compatible aliases. The 14 retired names stay callable: a
TOOL_ALIASES map in src/tool-aliases.ts rewrites each to its canonical tool
(and args) before validation in the CallTool dispatcher, logging a one-time
deprecation notice. They are hidden from ListTools by default; set
EVERNOTE_LEGACY_TOOLS=true to re-advertise them during a migration window.
See MIGRATION.md for the full old→new mapping.
Many tools accept user-friendly names but resolve to Evernote GUIDs
internally (notebook names → GUIDs via listNotebooks(), tag names → GUIDs
via listTags(), including hierarchical parent-tag resolution).
The read path is parameterized, not multiplied into extra tools: get_note
takes either guid (single, full detail incl. attachment text) or guids (a
≤25 batch, body-focused, with partial-results/resume on rate limit); both
get_note and search_notes take a format of markdown | text | enml;
search_notes takes offset + includeContent for paged full-text export
(query:"*" + notebookName + includeContent, page until hasMore is
false) — so there is no separate get_notes_batch or export_notebook tool.
Multi-note responses are bounded by EVERNOTE_MAX_RESPONSE_CHARS.
Token resolution is handled by EvernoteOAuth.getAccessToken() in
src/oauth.ts, in this order:
EVERNOTE_ACCESS_TOKENenv var (primary) —src/oauth.ts:21.- Claude Code OAuth token: only when env detection flags Claude Code and
OAUTH_TOKENis set —src/oauth.ts:38. .evernote-token.jsonin the current working directory (standalone fallback) —src/oauth.ts:13, loaded atsrc/oauth.ts:54.- If none resolve, the server emits relay-to-user instructions to stderr.
Claude Code is detected when MCP_TRANSPORT or CLAUDE_CODE_MCP is set
(src/oauth.ts:16). The standalone flow (src/auth-standalone.ts) launches
an Express server, handles /oauth/callback (src/auth-standalone.ts:149),
and writes .evernote-token.json (src/auth-standalone.ts:19) while also
printing the token for env-var migration.
- Never commit secrets/tokens. The following are gitignored — treat them as
sensitive and never paste their contents into logs, output, or memory
tools:
.env*,.evernote-token.json,.evernote-credentials.json,.cursor/mcp.json(.gitignore:5-13). - Redact credentials from any output you produce.
Values are loaded from .env into process.env via dotenv (config() is
called at src/index.ts:23, and again in src/auth-standalone.ts:17), then
read with direct process.env lookups. Defaults shown are the literal
fallbacks in code.
| Variable | Default | Read at | Purpose |
|---|---|---|---|
EVERNOTE_CONSUMER_KEY |
— (required) | src/index.ts:26 |
OAuth consumer key; server exits if missing (src/index.ts:29,44) |
EVERNOTE_CONSUMER_SECRET |
— (required) | src/index.ts:27 |
OAuth consumer secret; server exits if missing (src/index.ts:29,44) |
EVERNOTE_ENVIRONMENT |
production |
src/index.ts:28 |
production or sandbox (sandbox ⇒ sandbox: true, src/index.ts:68) |
EVERNOTE_ACCESS_TOKEN |
— | src/oauth.ts:21 |
Primary access token |
EVERNOTE_NOTESTORE_URL |
— | src/oauth.ts:25 |
NoteStore URL (fetched automatically if omitted) |
EVERNOTE_WEBAPI_URL |
— | src/oauth.ts:26 |
Web API URL prefix |
EVERNOTE_USER_ID |
— | src/oauth.ts:27 |
Evernote user ID (parsed int) |
OAUTH_TOKEN |
— | src/oauth.ts:38 |
Claude Code-provided token (only when Claude Code detected) |
OAUTH_NOTESTORE_URL |
— | src/oauth.ts:42 |
NoteStore URL for the Claude Code token |
OAUTH_WEBAPI_URL |
— | src/oauth.ts:43 |
Web API prefix for the Claude Code token |
OAUTH_USER_ID |
— | src/oauth.ts:44 |
User ID for the Claude Code token |
MCP_TRANSPORT |
— | src/oauth.ts:16 |
Presence flags Claude Code environment |
CLAUDE_CODE_MCP |
— | src/oauth.ts:16 |
Presence flags Claude Code environment |
OAUTH_CALLBACK_PORT |
3000 |
src/auth-standalone.ts:35 |
Standalone OAuth callback server port |
EVERNOTE_POLLING_ENABLED |
false (disabled) |
src/index.ts:56 |
Polling on only when value is exactly "true" |
EVERNOTE_POLL_INTERVAL |
3600000 (1 hour) |
src/index.ts:52 |
Poll interval in ms; floored to 15-min minimum (src/index.ts:48,50) |
EVERNOTE_WEBHOOK_URL |
— | src/index.ts:54 |
URL notified on detected changes |
EVERNOTE_WEBHOOK_SECRET |
— | src/index.ts:55 |
HMAC-SHA256 signing secret for webhook payloads |
EVERNOTE_ALLOWED_FILE_ROOTS |
[os.homedir(), process.cwd()] |
src/path-security.ts:9 |
path.delimiter-separated allow-list of roots for local attachments (default at src/path-security.ts:6) |
EVERNOTE_MAX_CONCURRENCY |
3 |
src/index.ts |
Max simultaneous NoteStore RPCs (shared FIFO semaphore in src/concurrency.ts); bounds burst width so a wide fan-out doesn't spike past the hourly rate limit |
EVERNOTE_RATE_LIMIT_AUTO_RETRY_SECONDS |
15 |
src/index.ts |
Auto-retry a rate-limited RPC (errorCode 19) once when Evernote's rateLimitDuration is ≤ this many seconds; 0 disables auto-retry. Longer waits return a structured error instead |
EVERNOTE_MAX_RESPONSE_CHARS |
60000 |
src/index.ts |
Total note-body character budget per multi-note response (get_note batch, search_notes with includeContent); bodies past the budget are dropped with truncated: true + original contentLength (see src/response-budget.ts). Keeps responses under the ~25k-token MCP cap |
EVERNOTE_NOTE_CACHE_SIZE |
200 |
src/index.ts |
Max notes held in the per-instance USN-keyed body cache (src/note-cache.ts); 0 disables it. Re-reading unchanged notes is served from memory instead of re-spending a getNote quota call |
EVERNOTE_NOTE_CACHE_SYNC_TTL_MS |
30000 |
src/index.ts |
How long a getSyncState result is trusted before the cache re-probes for external edits. Bounds the staleness window for edits made elsewhere; own writes evict immediately |
Notes:
- Rate-limit handling. Failed tool calls return a machine-parseable JSON
body (
src/errors.ts):{ error, tool, message, errorCode?, errorName?, retryAfterSeconds?, timestamp }. On the hourly rate limiterroris"rate_limited"andretryAfterSecondsis Evernote's exact backoff window — read it and reschedule rather than retrying blindly. Concurrency limiting shapes bursts; it does not restore quota (the quota is a per-token hourly call count). The only real quota levers are fewer calls (caching) and honoring the backoff. - Note body cache (
src/note-cache.ts). A per-EvernoteAPI-instance, USN-keyed, LRU cache of note bodies (ENML + metadata; resource binaries and recognition stripped).get_note(single + batch),search_notesincludeContent/includePreview, and previews read through it viaEvernoteAPI.getNoteCached. Invalidation is two-track: our own writes (updateNote/deleteNote, so alsopatch_note/add_resource_to_note) evict the GUID immediately (self-write consistency), while external edits are caught by a TTL-gatedgetSyncStateprobe that then loopsgetFilteredSyncChunkto evict precisely — deliberately not the polling path's single-chunk force-advance, which silently skips changes past 100. Net: staleness for edits made elsewhere is bounded byEVERNOTE_NOTE_CACHE_SYNC_TTL_MS; reads after our own writes are immediately consistent. Extracted OCR/attachment text is never cached (recognition updates async without a USN bump) — it is always re-extracted live on read. Building the cache inensureAPI()means reconnect/revoke_authstarts a fresh cache (multi-account safety). Polling's own >100-change truncation is a separate, pre-existing limitation and is out of scope for the cache. EVERNOTE_POLL_INTERVALis clamped: the effective interval ismax(15min, EVERNOTE_POLL_INTERVAL)— the 15-minute floor is an Evernote requirement (src/index.ts:48).EVERNOTE_POLLING_ENABLEDis a strict equality check against'true'(src/index.ts:56), so any other value (including unset) leaves polling off.- When changing webhook/polling behavior, validate against a real receiver
(e.g.
curl) and update/extend the relevant Jest tests.
- TypeScript with ES modules (
"type": "module"). Keep.jsextensions on relative imports in TS source (e.g.import { x } from './foo.js') — the build targets ES modules. - Prefer changing code in
src/and rebuilding; never editdist/directly. - Keep tool schemas (
src/tool-schemas.ts) and theirCallToolhandlers (src/index.ts) aligned: validate input and return clear error messages. - Run
npm run lint,npm run build, andnpm testbefore pushing.
mainis the integration branch and the PR target for day-to-day work. Open PRs againstmain. (Historical docs that routed work through adevelopbranch are stale — recent merged PRs targetmaindirectly.)mainis treated as stable; keep it green.- Release Please runs on
main(.github/workflows/release-please.yml,on: push: branches: [main]). Every push tomainupdates a Release PR; merging it bumpspackage.json, updatesCHANGELOG.md, tags a GitHub Release, and publishes to npm via OIDC trusted publishing (no token). - Do not hand-edit
CHANGELOG.md, the version inpackage.json, or.release-please-manifest.json— Release Please owns these. - CI (
.github/workflows/ci.yml,test.yml) runs lint + build + test on pushes and PRs to bothmainanddevelop.
This repo uses Conventional Commits so Release Please can generate releases reliably.
- PR titles must be Conventional Commit format (the repo squash-merges, so the PR title becomes the merge commit and feeds Release Please).
- Prefixes:
feat,fix,docs,chore,refactor,test,perf,build,ci,revert. Use!(feat!:) for breaking changes → major bump;feat:→ minor;fix:→ patch;chore/docs→ no bump. - Subjects are imperative mood.
fix: resolve notebook GUID lookup for nested tags
feat: add format projection to evernote_get_note
docs: clarify standalone OAuth callback port
chore: bump @modelcontextprotocol/sdk
- After behavioral changes, run
npm testand fix failures before stopping. - Iterate with narrower runs (
npm run test:unit,npm run test:integration,npm run test:e2e) but ensure the full suite is green before declaring success. - Sandbox testing: set
EVERNOTE_ENVIRONMENT=sandbox(requires a separate account at sandbox.evernote.com). For CI, preferEVERNOTE_ACCESS_TOKEN;.evernote-token.jsonis a local fallback only. - Quick connectivity checks:
evernote_connection({action:"status"})(or{action:"user"}) verifies auth;evernote_list_notebooksconfirms API connectivity.
Use the AutoMem memory MCP to keep persistent context across sessions for this repo.
Recall for project context, architecture/decisions, debugging, refactors, integrations, and "why/how should this work?" questions. Skip for trivial edits.
Example:
mcp__memory__recall_memory({
query: "<current task>",
tags: ["mcp-evernote"],
limit: 5
})Store only high-signal items (decisions, root causes, reusable patterns, preferences). Avoid noise and never store secrets.
mcp__memory__store_memory({
content: "Brief title. Context and details. Impact/outcome.",
tags: ["mcp-evernote", "<platform>", "<component>", "YYYY-MM"],
importance: 0.8,
metadata: {
type: "Insight", // Decision | Pattern | Preference | Style | Habit | Context
confidence: 0.95,
files_modified: ["src/index.ts"]
}
})If multiple files changed or the work was substantial, store a short "what changed + impact" memory.
Include: mcp-evernote + platform tag (e.g., cursor/codex) + component + current month (YYYY-MM).
If memory tools fail or return nothing, continue without mentioning it; memory is an enhancement, not a blocker.