Skip to content

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.

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

The create project dialog in the Appwrite Console with the name Recall and project ID recall
The create project dialog in the Appwrite Console with the name Recall and project ID recall

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

The create database wizard with the VectorsDB type selected
The create database wizard with the VectorsDB type selected

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.

The specifications step of the create database wizard showing compute tiers
The specifications step of the create database wizard showing compute tiers

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

The create collection dialog with the all-minilm embedding model selected
The create collection dialog with the all-minilm embedding model selected

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:

JavaScript
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 indexes tab of the memories collection showing the hnsw_cosine index as available
The indexes tab of the memories collection showing the hnsw_cosine index as available

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 create API key drawer with the required scopes selected
The create API key drawer with the required scopes selected

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:

Bash
APPWRITE_ENDPOINT=https://<REGION>.cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=recall
APPWRITE_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

The OAuth2 server settings page showing the server active, the authorization URL, and the memories scopes
The OAuth2 server settings page showing the server active, the authorization URL, and the memories scopes

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.read and memories.write. These are the permissions AI clients will request, alongside the built-in openid, profile, email, and phone.

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.

JavaScript
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:

JavaScript
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.

JavaScript
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:

JavaScript
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:

ToolScopeWhat it does
remembermemories.writeEmbeds a fact and stores it as a new memory
recallmemories.readSearches memories by meaning with cosine similarity
list_memoriesmemories.readLists the most recent memories, newest first
forgetmemories.writeDeletes 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:

JSON
{
"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:

Bash
appwrite login
appwrite push function --function-id memory-mcp --force --activate

The function page in the Appwrite Console showing an active deployment and the function domain
The function page in the Appwrite Console showing an active deployment and the function domain

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.

The Recall dashboard with saved memories and a semantic search box
The Recall dashboard with saved memories and a semantic search box

Then register the MCP server with Claude Code, using the function domain from the deploy output:

Bash
claude mcp add --transport http recall https://<FUNCTION_DOMAIN>/mcp

Run /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 Recall consent screen asking the user to grant Claude Code access to their memories
The Recall consent screen asking the user to grant Claude Code access to their memories

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:

Plain text
> 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.

The connected apps page showing Claude Code with its granted permissions and a disconnect button
The connected apps page showing Claude Code with its granted permissions and a disconnect button

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:

Read next

Appwrite now speaks Postgres

Jake Barnby

Run a managed PostgreSQL instance inside your Appwrite project and connect to it with psql, your ORM, and the entire PostgreSQL ecosystem.

6 min read

Ready to build?_