Giving an AI the keys without giving it the building: how we designed secure MCP access for note2it
An architecture walkthrough of note2it's MCP connector: OAuth 2.1 with PKCE, scopes as a mask over RBAC, tenancy from the token, an audit of every tool call.
When we connected note2it to Claude and ChatGPT through the Model Context Protocol (MCP), the product question was easy. People want their AI assistant to search their notes, read a meeting summary, and write the follow-up page. The engineering question was harder: how do you let a language model act inside a multi-tenant SaaS application without making it the weakest point in your security model?
An MCP server is an API whose caller is a model. The model follows instructions, sometimes instructions it found inside the very content it was asked to read. It retries, it guesses arguments, and it runs on someone else's infrastructure with a token you issued. So we designed the connector on one assumption: the model is not a security boundary. Everything that keeps one customer's notes away from another, and keeps a mistake recoverable, has to hold even if the model is confused, manipulated, or simply wrong.
This is how that works in note2it today.
The shape of the problem
note2it is a multi-tenant note-taking app: notebooks, sections, and pages, with meeting recordings, tasks, and sharing. It is built to be audit-ready (SOC 2 scope), which gives us a set of non-negotiables that predate MCP:
One data path. UI → API client → /api/v1 route → service layer → database. Nothing queries the database outside the service layer.
Every route runs the same five steps: authenticate → authorize → validate → execute → audit.
Tenant isolation comes from the session, never from client input.
Deny by default. No permission, no access.
Every mutation is audited, success and failure, append-only, with no personal data in the trail.
The rule for MCP was simple: the connector gets no exceptions. It is not a side door with its own database queries. It is one more client of the same services, with one extra gate.
Principle 1: one data path, one extra gate
Every MCP tool is a small spec: a name, a description written for the model, a Zod input schema, the RBAC permission it needs, the token scope it needs, and a handler that calls lib/services/* and nothing else. Tools never touch the database.
A tool call then runs through a pipeline that mirrors our REST routes:
authenticate (bearer token)
→ rate-limit (per token: a read bucket, plus a tighter write bucket)
→ scope mask (does this TOKEN allow this permission?)
→ authorize (does this ROLE allow this permission?)
→ validate (Zod)
→ execute (the same service the web app uses)
→ audit-log (success and failure)The last four steps are literally the same function our web API uses. The MCP pipeline adds the token-specific gates in front. That reuse matters more than any single control. When we fix or tighten something in the shared pipeline, the web app, the iOS app, and every AI assistant get the fix at once, and there is no second implementation that can drift.
Principle 2: tenancy comes from the token, never from the arguments
The most important line in the whole connector is a comment:
/** The authenticated caller derived from a bearer token. `userId` comes from
* the token record ONLY — tool input can never influence tenancy. */No tool accepts a user ID. Tools accept page IDs, notebook names, search queries. The user is resolved from the bearer token on the server, and every service query starts from that user. Search, for example, builds its filter from the caller and nothing else:
export async function accessFilter(userId: string): Promise<Filter<PageDoc>> {
const sharedIds = await listSharedNotebookIds(userId);
return sharedIds.length
? { $or: [{ ownerId: userId }, { notebookId: { $in: sharedIds } }], ...NOT_TRASHED }
: { ownerId: userId, ...NOT_TRASHED };
}A model that is tricked into asking for "page 6a1f… belonging to another customer" gets the same answer as a model asking for a page that doesn't exist: not found. Not "forbidden", which would confirm the page exists. The question of whose data this is never reaches the model at all.
Principle 3: authorization is an intersection, not a union
note2it has a role-based permission matrix: user (owner), viewer (read-only), and superadmin (account lifecycle only; it can never read your notes or files). MCP tokens add scopes. The temptation is to make scopes a second, independent permission system. We didn't. Scopes are a mask over RBAC:
// A tool call is allowed only when BOTH checks pass:
// hasPermission(role, permission) AND scopeAllows(token.scopes, permission)
export function scopeAllows(scopes: readonly McpScope[], permission: Permission): boolean {
return scopes.some((s) => SCOPE_PERMISSIONS[s]?.includes(permission) ?? false);
}A token can only ever narrow what its owner can do, never widen it. A viewer holding a notes:write token still cannot write. An owner holding a notes:read token still cannot change anything. An unknown scope or an unknown permission evaluates to false.
There are exactly two MCP scopes: notes:read and notes:write. Just as important is what cannot be expressed as an MCP scope at all:
Account and admin actions. No token can change your account, see billing, or reach any admin function.
Sharing. An AI assistant cannot share your notebook with anyone.
Our own AI features, which have their own metering.
Permanent media deletion.
And one deliberate absence that we think is the right default for any SaaS: account deletion is browser-session-only. The permission isn't in any token scope map, so no MCP client, personal token, or mobile app token can delete an account. The worst a compromised AI integration can do is bounded by design, not by the model's good behaviour.
Two ways in: personal tokens and OAuth 2.1
Users connect an assistant in one of two ways.
Personal API tokens, for Claude Code, scripts, and clients without OAuth. A token is 32 random bytes behind an n2i_ prefix (so secret scanners can spot one in a leaked log). It is shown once, and only its SHA-256 hash is stored. Tokens are scoped, optionally expiring, individually revocable, capped at ten active per user, and they record when they were last used.
OAuth 2.1, for Claude.ai, ChatGPT, and any client that supports MCP's authorization flow. We built a deliberately small authorization server rather than adopting a general-purpose one, because every feature we didn't build is a feature that can't be misconfigured:
Authorization code + PKCE only, with S256 mandatory. No implicit flow, no password grant, no client secrets (every client is public), no ID tokens, no signing keys.
Dynamic client registration (RFC 7591) with strict redirect URIs: https, loopback http for local tools, or a private-use scheme for native apps. Never plain http to a remote host, and never a URI with embedded credentials.
Authorization codes live 60 seconds and are single-use, consumed atomically (findOneAndDelete, so a replayed code finds nothing). Each code is bound to the client, the redirect URI, and the PKCE challenge, and carries the scopes and the RFC 8707 resource it was issued for. A mismatch fails the exchange.
Access tokens live one hour; refresh tokens live 30 days and rotate on every use, with reuse detection. Claiming a refresh token is a single conditional update, so two concurrent refreshes can't both succeed. If a rotated token is ever presented again, we treat it as theft and revoke the entire grant, as OAuth 2.1 recommends.
Discovery is standard. RFC 9728 protected-resource metadata points clients to the authorization server, and a 401 from the MCP endpoint tells them where to look.
The consent screen is our own UI, session-authenticated and origin-checked. A cross-site form cannot approve a grant, and the server re-validates every field instead of trusting what the consent page rendered.
Why opaque tokens instead of JWTs
Every access token is an opaque random string, stored hash-only, and looked up on every request. That costs a database read per call. It buys us three things we care about more than that read:
1. Revocation is instant. Disconnect an app in Settings and its next call fails. No waiting for a JWT to expire.
2. Account changes apply within one request. Each verification re-reads the user, so deactivation, a role change, or a plan downgrade takes effect on the very next tool call.
3. There is nothing to leak at rest. A database dump contains hashes, not credentials, and there is no signing key to steal.
Two kinds of client, kept apart
note2it has two very different kinds of bearer-token client:
First-party apps (our iPhone and Apple Watch apps) talk to /api/v1, use their own scope set, and are registered clients we control.
Third-party MCP clients (Claude, ChatGPT, and anything else a user connects) talk only to the MCP endpoint and carry only the MCP scopes.
We keep these as separate trust profiles and enforce the separation in both directions. A first-party token presented to the MCP endpoint is rejected and audited as a token on the wrong surface. A third-party client is capped to MCP scopes at consent time, so an over-broad grant is never even recorded. If a third-party token somehow carries a first-party-only scope, that scope is dropped at verification, and a token left with nothing is denied.
We learned to be this strict the honest way. Both scope sets share the names notes:read and notes:write. In August, an internal security review found that a third-party token holding notes:write could have been evaluated against the first-party permission mask on /api/v1, which grants more (task management, for example). The fix was to make "was this token issued to a registered first-party client?" an explicit, verified property of every principal, and to apply the first-party mask only when it's true.
The same shared names caused a field bug two days later. The MCP connector is a paid-plan feature, and the plan check looked at scope names. Every iPhone token carries notes:read, so free-plan users were locked out of the mobile app at their first API call. The fix: the plan gate applies to MCP-profile tokens only, and the MCP endpoint refuses first-party tokens outright. On the MCP surface, authentication is the plan gate.
The lesson generalises: a scope name is not an identity. If two kinds of client can hold the same scope string, the thing that tells them apart has to be verified on the server, not inferred.
An audit trail that covers the model
Every MCP tool call is audited, success and failure, into an append-only trail. Each entry records a stable action (page.search, page.update), the outcome, the acting user and role, the target, a request ID, and { via: "mcp", clientId, tokenId }. No content, no email addresses, no personal data.
Failures the pipeline owns are audited too: rate limits, missing scopes, and rejected bearer tokens. A rejected token is labelled either as an invalid bearer or as a valid first-party token on the wrong surface, because those are two different investigations. Audit writes for those gate denials are best-effort, so an audit failure can never turn a 401 into a 500. Once a call is past the gates, the audit is part of the request.
This answers the question security reviewers actually ask: "If an AI assistant changed something, can you show me what, when, and through which connection?" Yes, down to the individual token. Users can also see their own trail in Settings.
Writes you can undo
Read access is about isolation. Write access is also about recoverability, because a model will eventually do something its user didn't mean.
Writes need both the notes:write scope and an owner role. Pages and notebooks shared with you read-only never accept writes, whatever the token says.
Every edit to a page's content is snapshotted first, so it shows up in page history and can be restored.
Patches use optimistic concurrency. The model must pass the updatedAt it last read, so it can't silently overwrite an edit the user made a minute ago. It gets a conflict and re-reads instead.
"Delete" means trash. The MCP trash tool is soft and restorable. Permanent deletion is an owner action in the app only.
Tools carry honest annotations (readOnlyHint, destructiveHint), so hosts like Claude can ask for confirmation before anything that changes data.
We also cap tool results at about 25,000 tokens and tell the model how to narrow its request ("add filters, lower the limit, fetch one page"). That protects the model's context, and it keeps one runaway query from turning into a bulk export.
The one tool that reaches the internet
One MCP tool imports an image from a URL into a page. Any feature where the caller picks a URL your server will fetch is an SSRF primitive, so that tool is built as one:
https on port 443 only, with no embedded credentials and no http downgrade.
Every DNS answer must be public. Loopback, private ranges, link-local (including the cloud-metadata address), CGNAT, multicast, and reserved ranges are rejected in IPv4, IPv6, and IPv4-mapped forms. If any address in the answer set fails, the whole request fails.
The connection can only reach an address we approved. The socket's own DNS lookup re-resolves and re-validates, which defeats DNS rebinding.
Redirects are followed manually, capped, and each hop is fully re-validated.
Size is capped as bytes arrive, not by trusting content-length.
The declared type and the file's magic bytes must agree, against a raster-only allowlist (no SVG).
Error messages are constant. They never reveal what a host resolved to.
What we deliberately didn't build
Some of the most important decisions were omissions:
No impersonation and no support back door. Our superadmin role manages account lifecycle and sees per-account counts. It has no permission to read your notes, through MCP or anywhere else. The one exception: our internal audit-log viewer shows the titles of the pages, notebooks, and sections named in audit events. Titles are looked up when the log is viewed and are never stored in the trail.
No sessions on the MCP transport. The endpoint is stateless Streamable HTTP. Every call is authenticated on its own, so there is no session to hijack or pin.
No client secrets, no JWTs, no signing keys. Less to rotate, less to leak.
No scope for anything that changes who can see your data. Sharing, account settings, and deletion stay in the app, behind a signed-in browser session.
A kill switch. The whole MCP surface, including its OAuth metadata, sits behind one flag and answers 404 when it's off.
A checklist for your own MCP server
If you're putting an MCP server in front of a multi-tenant product, this is what we'd tell you:
1. Make MCP a client of your existing service layer, not a new data path.
2. Derive the tenant from the token on the server. Never accept a user or tenant ID as a tool argument.
3. Make scopes a mask over your permissions (intersection, not union), and deny by default.
4. Keep account, sharing, billing, admin, and deletion out of every token scope.
5. Use OAuth 2.1 with mandatory PKCE, single-use bound codes, and rotating refresh tokens with reuse detection.
6. Prefer opaque, hash-stored tokens if you need instant revocation and per-request account checks.
7. Keep first-party and third-party clients as separate, verified trust profiles. A scope name is not an identity.
8. Audit every tool call, success and failure, with the token and client it came through, and no content.
9. Make writes reversible: snapshots, optimistic concurrency, soft delete.
10. Treat any URL-fetching tool as an SSRF primitive and guard it accordingly.
11. Assume the model will be wrong or manipulated, and make sure your answer to "what's the worst it can do?" is short.
note2it's MCP connector is available on the Premium and Pro plans. To try it with Claude, read Connect Claude to your notes, or go straight to the setup page for every supported client. For a plain-language account of what an assistant can and can't see, read What your AI assistant can see. Start free at note2it.com.