Build a memory MCP server on Appwrite_
Give your AI tools a shared, persistent memory. Host a stateless MCP server on Appwrite Functions, store memories in VectorsDB, and protect it with your project's OAuth2 server.

AI assistants forget everything between conversations. Preferences repeated on Monday are gone by Tuesday, and every tool starts from zero even when another tool already knows the answer. The fix is to move memory out of the assistant and into a service the user owns, one that any AI client can read and write with the user's permission.
This tutorial builds that service: Recall, a small memory app on Appwrite. It ships a remote MCP server hosted as an Appwrite Function, stores memories in a VectorsDB database so they can be searched by meaning, and protects everything with the project's OAuth2 server. At the end, Claude Code connects to it, asks the user for permission on a consent screen, and starts remembering things.
The complete project lives at appwrite-community/memory-mcp, including the web app, the consent screen, and the MCP function. It pins node-appwrite to the latest major release.
How the pieces fit together
Three Appwrite products carry the whole workload.
An Appwrite Function hosts the MCP server. The Model Context Protocol's current revision is stateless: there is no initialization handshake and no session identifier, and every request stands on its own. That is exactly the shape of a serverless function. One HTTP handler serves protocol discovery, rejects unauthenticated requests, and answers tool calls, using createMcpHandler from the official @modelcontextprotocol/server SDK with plain JSON responses instead of a server-sent-events stream.
VectorsDB stores the memories. Each memory is one document: the text embedded as a vector, plus a metadata object carrying the text itself, the owner's user ID, and optional tags. Appwrite's Embeddings service generates the vectors server-side with the all-minilm model, and an HNSW cosine index makes similarity search fast. The recall tool is a single listDocuments call with a vectorCosine query.
The OAuth2 server handles permission. AI clients never see an API key. Claude Code identifies itself with a hosted client metadata URL, the user approves access on a consent screen the app hosts, and Appwrite mints short-lived access tokens scoped to memories.read and memories.write. The function verifies each token's signature against the project's public keys before serving a single tool call.
The repository is a pnpm monorepo: apps/recall is the web app (dashboard, sign-in, consent screen), and functions/memory-mcp is the MCP server.
Create the project

In the Appwrite Console, create a new project. Set the name to Recall, choose a custom project ID of recall, and pick a region close to your users. The project ID appears in every OAuth2 URL later, so a readable ID keeps the discovery documents easy to follow.
Create the vector database

Open Databases and select Create database. The wizard asks for a database type: pick VectorsDB, the vector database built for embeddings and similarity search. Name the database Recall and give it the custom ID recall.

Select your preferred tier, then select Create database. The tier controls the compute and memory behind the database and can be changed later as the workload grows.
Create the memories collection

Inside the database, select Create collection. Name it Memories, set the custom ID to memories, and choose the all-minilm embedding model. The model fixes the collection's vector dimension at 384, which keeps embeddings compact and queries fast, a good fit for short personal notes.
Every VectorsDB collection provisions the same two fields automatically: embeddings, which holds the vector, and metadata, an object for anything else you want to store alongside it. Recall keeps the memory text, the owner's user ID, and a list of tags in metadata.
Similarity search needs an index on the embeddings field. Create it with the SDK:
import { Client, VectorsDB, VectorsDBIndexType } from 'node-appwrite'
const client = new Client() .setEndpoint('https://<REGION>.cloud.appwrite.io/v1') .setProject('recall') .setKey('<YOUR_API_KEY>')
const vectorsDB = new VectorsDB(client)
await vectorsDB.createIndex({ databaseId: 'recall', collectionId: 'memories', key: 'embeddings_cosine', type: VectorsDBIndexType.HnswCosine, attributes: ['embeddings'],})
The index shows up on the collection's Indexes tab and switches to Available once it finishes building. hnsw_cosine matches how text embeddings are usually compared; VectorsDB also supports hnsw_dot and hnsw_euclidean for models tuned to those distance functions.
Create an API key

The web app's server needs an API key for the operations a user session cannot perform: creating sessions during sign-in, generating embeddings, and reading the collection. Open API Keys, select Create API key, and grant it the scopes sessions.write, users.read, apps.read, vectorsdb.read, vectorsdb.collections.read, vectorsdb.collections.write, vectorsdb.indexes.write, vectorsdb.documents.read, vectorsdb.documents.write, and embeddings.write.
Copy the secret into the repository's .env file along with the endpoint and project ID:
APPWRITE_ENDPOINT=https://<REGION>.cloud.appwrite.io/v1APPWRITE_PROJECT_ID=recallAPPWRITE_API_KEY=<API_KEY>SESSION_SECRET=<A_LONG_RANDOM_STRING_AT_LEAST_32_CHARS>The MCP function itself never touches this key. Appwrite hands the function a fresh, scoped key on every execution, so the deployment carries no long-lived secret.
Enable the OAuth2 server

Open Auth, switch to the OAuth2 server tab, and enable the server. Two settings connect it to Recall:
- Authorization URL:
http://localhost:4200/oauth/consent. When a client starts an authorization, Appwrite sends the user here. The app signs the user in if needed and renders the consent card. - Scopes: add
memories.readandmemories.write. These are the permissions AI clients will request, alongside the built-inopenid,profile,email, andphone.
After the update, the project publishes standard discovery documents, and any OAuth library can configure itself from the OIDC discovery URL shown on this page.
MCP clients register themselves without any manual setup. Claude Code identifies itself with a URL pointing at its hosted client metadata, Appwrite fetches the document to learn the client's name and redirect URIs, and no pre-registration step is involved. This matters for MCP because a server has no idea which clients will connect to it ahead of time; any tool a user trusts can walk up, identify itself, and ask.
Store and search memories with VectorsDB
Both the web app and the MCP server share one data model. Writing a memory is two SDK calls: embed the text, then store the document. The Embeddings service runs the model server-side, so no external embedding API is involved.
import { Client, Embeddings, EmbeddingModel, ID, Permission, Role, VectorsDB } from 'node-appwrite'
async function embed(embeddings, text) { const result = await embeddings.createTextEmbeddings({ texts: [text], model: EmbeddingModel.Allminilm, }) return result.embeddings[0].embedding}
export async function storeMemory(client, userId, text, tags) { const vectorsDB = new VectorsDB(client) return vectorsDB.createDocument({ databaseId: 'recall', collectionId: 'memories', documentId: ID.unique(), data: { embeddings: await embed(new Embeddings(client), text), metadata: { userId, text, tags }, }, permissions: [Permission.read(Role.user(userId)), Permission.delete(Role.user(userId))], })}Searching is one query. Query.vectorCosine ranks documents by similarity to the query vector, and a filter on metadata.userId keeps every user inside their own memories:
import { Query } from 'node-appwrite'
export async function searchMemories(client, userId, query, limit) { const vectorsDB = new VectorsDB(client) const result = await vectorsDB.listDocuments({ databaseId: 'recall', collectionId: 'memories', queries: [ Query.vectorCosine('embeddings', await embed(new Embeddings(client), query)), Query.equal('metadata.userId', userId), Query.limit(limit), ], }) return result.documents}This is what makes the recall tool useful: a memory saved as "I prefer TypeScript over JavaScript for all new projects" comes back for the query "what language do I like?", with no keyword in common.
Build the MCP server as a Function
The function is one HTTP handler with three jobs: serve OAuth discovery metadata, turn away requests without a valid token, and answer MCP requests for authenticated users.
import { createMcpHandler, getOAuthProtectedResourceMetadataUrl, oauthMetadataResponse, requireBearerAuth,} from '@modelcontextprotocol/server'
export const SCOPES = ['openid', 'memories.read', 'memories.write']
const mcp = createMcpHandler( ({ authInfo }) => buildServer({ userId: authInfo.extra.userId, scopes: authInfo.scopes, apiKey: authInfo.extra.apiKey, }), // Functions return a single response, so ask the SDK for plain JSON // rather than a server-sent-events stream. { responseMode: 'json' },)
export default async ({ req, res, error }) => { const request = toWebRequest(req) const resourceServerUrl = new URL('/mcp', new URL(request.url).origin)
const discovery = oauthMetadataResponse(request, { oauthMetadata: await getAuthServerMetadata(), resourceServerUrl, scopesSupported: SCOPES, resourceName: 'Recall', }) if (discovery) return send(res, discovery)
const gate = requireBearerAuth({ verifier: createVerifier(resourceServerUrl), resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl), }) const auth = await gate(request) if (auth instanceof Response) return send(res, auth)
// The function's own key, minted by Appwrite for this execution. auth.extra = { ...auth.extra, apiKey: req.headers['x-appwrite-key'] } return send(res, await mcp.fetch(request, { authInfo: auth }))}Statelessness does the heavy lifting here. Because the handler builds a fresh MCP server per request for the exact user the token belongs to, there is nothing to keep warm between calls and nothing to lose when the container recycles.
Token verification uses the project's published signing keys. Access tokens are RFC 9068 JWTs typed at+jwt, and the verifier checks the signature, the issuer, the audience, and the type in one call:
import { createRemoteJWKSet, jwtVerify } from 'jose'
const ISSUER = `${process.env.APPWRITE_FUNCTION_API_ENDPOINT}/oauth2/${process.env.APPWRITE_FUNCTION_PROJECT_ID}`const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))
export function createVerifier(resource) { return { async verifyAccessToken(token) { const { payload } = await jwtVerify(token, jwks, { issuer: ISSUER, audience: resource.href, typ: 'at+jwt', }) return { token, clientId: payload.client_id ?? '', scopes: (payload.scope ?? '').split(' ').filter(Boolean), expiresAt: payload.exp, resource, extra: { userId: payload.sub }, } }, }}The server itself registers four tools, each gated on the scope the user actually approved:
| Tool | Scope | What it does |
|---|---|---|
remember | memories.write | Embeds a fact and stores it as a new memory |
recall | memories.read | Searches memories by meaning with cosine similarity |
list_memories | memories.read | Lists the most recent memories, newest first |
forget | memories.write | Deletes one memory by ID, after checking ownership |
Every tool call filters or verifies against the userId from the token, so one user's assistant can never read another user's memories, even though all memories share a collection.
Deploy the function
The function is declared in appwrite.config.json, including the scopes for the key Appwrite mints per execution:
{ "projectId": "recall", "functions": [ { "$id": "memory-mcp", "name": "Recall MCP", "runtime": "node-22", "execute": ["any"], "scopes": ["vectorsdb.documents.read", "vectorsdb.documents.write", "embeddings.write"], "entrypoint": "dist/main.js", "commands": "npm install && npm run build", "path": "functions/memory-mcp" } ]}Deploy it with the Appwrite CLI:
appwrite loginappwrite push function --function-id memory-mcp --force --activate
The deploy output prints the function's domain. That domain plus /mcp is the MCP server URL, and it doubles as the OAuth2 resource identifier: tokens are minted for that exact URL and for nothing else, so a token stolen from one MCP server is useless against another.
Run the app and connect Claude Code
Start the web app with pnpm dev, sign up for an account, and save a memory or two from the dashboard.

Then register the MCP server with Claude Code, using the function domain from the deploy output:
claude mcp add --transport http recall https://<FUNCTION_DOMAIN>/mcpRun /mcp inside Claude Code and pick Authenticate. Claude Code fetches the protected-resource metadata from the function, discovers the project's OAuth2 server from it, and opens the browser on Recall's consent screen.

The card shows exactly what was requested: who is asking, which permissions, and which MCP server the tokens will be limited to. After the user selects Allow access, Claude Code exchanges its authorization code for tokens and the tools go live:
> Remember that our production API keys rotate on the first Monday of each month.
⏺ recall - remember (MCP) ⎿ Saved: "Production API keys rotate on the first Monday of each month"
> When do our API keys rotate?
⏺ recall - recall (MCP) ⎿ "Production API keys rotate on the first Monday of each month"Memories saved from Claude Code appear on the Recall dashboard immediately, and memories saved in the dashboard are searchable from Claude Code, because both sides read and write the same VectorsDB collection.
The connection stays under the user's control. The consent is stored on their account, and the connected apps page lists it with a disconnect button that revokes future token refreshes.

Where to take it
The whole server is around 400 lines, and most of the interesting behavior comes from the platform underneath it: Functions gave the MCP server a public HTTPS endpoint and per-execution credentials, VectorsDB turned semantic memory into two SDK calls, and the OAuth2 server handled registration, consent, tokens, and revocation without any auth code in the function beyond a JWT check.
From here you can extend the memory model with tags and time-based decay, add an update_memory tool, or point several MCP clients at the same server so a memory saved in one shows up in all of them. The complete code, including the consent screen and the dashboard, is in the companion repository, and the deployed result is live: the app at recall.appwrite.network and the MCP server at https://recall-mcp.appwrite.network/mcp.
Further reading:





