Skip to content

Turn your app into an MCP server with the Appwrite OAuth2 server_

Build a remote MCP server for your product, host it on Appwrite Functions, and let AI tools like Claude Code sign in through the OAuth2 server built into your Appwrite project.

Your users already live inside AI tools. They plan their week in Claude, write code in Cursor, and expect the products they use to be reachable from those conversations. The way a product becomes reachable is an MCP server: a web service that AI tools call to list a user's data and act on it, with the user's permission.

Most teams stall on the same two problems. First, auth: the MCP specification requires OAuth 2.1, with consent screens, short-lived tokens, and clients that can register themselves. Building an authorization server is months of specification reading. Second, hosting: MCP servers used to be long-lived, stateful processes, which ruled out serverless platforms.

Both problems have now disappeared. Every Appwrite project includes an OAuth2 server that implements exactly the standards MCP requires. And MCP specification 2026-07-28 made the protocol stateless, so a plain HTTP function can serve it.

In this tutorial you will build a complete, working MCP server for a task manager called TaskFlow. By the end, you will type "add a task to review the launch checklist" into Claude Code, approve access on a consent screen you own, and watch the task appear in your app. The full project is available on GitHub.

What we are building

The TaskFlow dashboard with tasks created from Claude Code
The TaskFlow dashboard with tasks created from Claude Code

TaskFlow is a small task manager built on Appwrite: email sign-in through Appwrite Auth and a tasks table in Appwrite Databases. The web app is a TanStack Start project, though nothing in this tutorial depends on the framework.

We will add two things to it:

  • An MCP server, deployed as an Appwrite Function, exposing three tools: list_tasks, create_task, and complete_task. Every call runs as the signed-in TaskFlow user, never as an admin.
  • A consent screen inside the TaskFlow web app, where a user reviews what an AI tool is asking for and approves or declines.

The Appwrite OAuth2 server sits between them and handles the protocol: it validates clients, shows users to your consent screen, mints the tokens, and publishes the public keys your MCP server uses to verify them.

The flow, end to end:

  1. A user adds TaskFlow's MCP server to Claude Code.
  2. Claude Code asks the server for data and receives a response saying "sign in first, here is the OAuth2 server to talk to".
  3. Claude Code opens a browser to your project's authorization page, which sends the user to TaskFlow's consent screen.
  4. The user signs in to TaskFlow and approves access.
  5. Claude Code exchanges the resulting code for an access token and retries. From here on, every tool call carries a token that identifies the user and what they approved.

Enable the OAuth2 server

The OAuth2 server settings in the Appwrite Console
The OAuth2 server settings in the Appwrite Console

Create a project in the Appwrite Console and head to Auth, then the OAuth2 server tab. Turn the server on and set two things:

  • Authorization URL: the page in your app that will host the consent screen. While developing this is http://localhost:4100/oauth/consent; after you deploy TaskFlow at the end of this tutorial, you will change it to the deployed address, https://taskflow.appwrite.network/oauth/consent in our case.
  • Scopes: add tasks.read and tasks.write.

A scope is a named permission that a user can grant or refuse. The built-in scopes (openid, profile, email, phone) cover identity. Custom scopes describe your product. Defining tasks.read separately from tasks.write means a user can later connect a reporting tool that only reads tasks, without also letting it create any.

That is the entire server setup. Your project now answers on a set of standard addresses under /v1/oauth2/<PROJECT_ID>, and publishes a discovery document that OAuth libraries and MCP clients read to find them:

Plain text
https://<REGION>.cloud.appwrite.io/v1/oauth2/<PROJECT_ID>/.well-known/openid-configuration

While you are in the Console, also create an API key for the TaskFlow web app under API Keys, with the sessions.write, users.read, apps.read, databases.read, databases.write, tables.read, and tables.write scopes. The app uses it to sign users in on the server and to look up client details for the consent screen.

How AI tools get a client ID without asking you

Here is the part that surprises people coming from classic OAuth. When you added "Sign in with Google" to an app, you first visited a developer console, filled in a form, and copied a client ID. That works for one integration built by one team. It cannot work for MCP: thousands of AI tools exist, users pick them freely, and none of those tools' developers know your product exists. Nobody can fill in a form first.

So the MCP specification requires that clients can introduce themselves, and the Appwrite OAuth2 server supports both standard ways of doing that:

  • Dynamic client registration. The tool sends one request to your project's registration endpoint with its name and redirect addresses, and receives a client ID in the response. No account, no approval step. Registration is rate limited per IP to prevent abuse.
  • Client ID metadata documents. The tool skips registration entirely and uses a URL it controls as its client ID. Your project fetches that URL and reads the tool's name and redirect addresses from it.

Claude Code uses the second method. Its client ID is literally this URL, and your project reads everything it needs from it:

JSON
{
"client_id": "https://claude.ai/oauth/claude-code-client-metadata",
"client_name": "Claude Code",
"client_uri": "https://claude.ai",
"redirect_uris": ["http://localhost/callback", "http://127.0.0.1/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}

If "anyone can get a client ID" sounds alarming, notice what a client ID does not grant: nothing. It only lets a tool start an authorization request. Access to a user's data still requires that user to sign in to your product and approve the request on your consent screen, and the token that comes out is limited to the scopes they approved. The client ID is a name tag, not a key.

Provision the database

The MCP server and the web app share one tasks table: a title, a done flag, and the owner's userId. The repository includes a script that creates it with the Appwrite Node.js SDK:

JavaScript
import { Client, Permission, Role, TablesDB } from 'node-appwrite'
const client = new Client()
.setEndpoint(process.env.APPWRITE_ENDPOINT)
.setProject(process.env.APPWRITE_PROJECT_ID)
.setKey(process.env.APPWRITE_API_KEY)
const tablesDB = new TablesDB(client)
await tablesDB.create({ databaseId: 'taskflow', name: 'TaskFlow' })
await tablesDB.createTable({
databaseId: 'taskflow',
tableId: 'tasks',
name: 'Tasks',
permissions: [Permission.create(Role.users())],
rowSecurity: true,
})

With rowSecurity on, each row carries its own permissions, so a task is only readable and writable by the user who owns it. Run it from the repository root:

Bash
pnpm provision

TaskFlow's consent screen showing a request from Claude Code
TaskFlow's consent screen showing a request from Claude Code

The OAuth2 server handles the protocol, but the page where a user decides is a page you own, built with your components and your brand. That is a deliberate design choice: your users approve access inside your product, not on an Appwrite page.

The contract is simple. When a tool starts an authorization request, Appwrite sends the user's browser to your authorization URL:

  • If the user is signed in, the URL carries a grant_id: a record of what the tool is asking for.
  • If the user is not signed in, the URL carries the original request parameters instead. Your page signs the user in, then replays those parameters to receive a grant_id.

Your page reads the grant, shows what is being requested, and calls approve or reject with the user's decision. TaskFlow does this in a TanStack Start server function using the Node.js SDK, authenticated as the signed-in user:

TypeScript
import { Client, Oauth2 } from 'node-appwrite'
const client = new Client()
.setEndpoint(process.env.APPWRITE_ENDPOINT)
.setProject(process.env.APPWRITE_PROJECT_ID)
.setSession(userSessionSecret)
const oauth2 = new Oauth2(client)
// No grant yet: replay the authorization request for the signed-in user.
// If the user already approved these scopes earlier, Appwrite skips the
// consent step and returns the redirect immediately.
const result = await oauth2.authorize({ ...requestParams })
if (result.redirectUrl) throw redirect({ href: result.redirectUrl })
// Load the grant to render the consent card.
const grant = await oauth2.getGrant({ grantId: result.grantId })
// grant.scopes -> ['openid', 'tasks.read', 'tasks.write']
// grant.appId -> who is asking
// grant.resources -> which server the token will be locked to

The decision is one call each way. Both return the address to send the user back to, with Appwrite appending the authorization code on approval:

TypeScript
const { redirectUrl } = await oauth2.approve({ grantId: grant.$id })
// or
const { redirectUrl } = await oauth2.reject({ grantId: grant.$id })

One detail worth copying from the screenshot above: TaskFlow translates scopes into sentences. Users should read "Create tasks and mark them as done", not tasks.write. And because a client like Claude Code identifies itself with a metadata URL, TaskFlow fetches that URL on the server to put a proper name on the card:

TypeScript
async function describeClient(appId: string) {
if (appId.startsWith('https://')) {
const response = await fetch(appId, { headers: { Accept: 'application/json' } })
const metadata = await response.json()
return { id: appId, name: metadata.client_name ?? appId }
}
const app = await new Apps(adminClient).get({ appId })
return { id: app.$id, name: app.name }
}

Build the MCP server

Now the server itself. MCP specification 2026-07-28 removed the session handshake from the protocol: every request carries what the server needs to answer it, and no state lives between calls. That is exactly the shape of an Appwrite Function, so hosting is a natural fit. The official TypeScript SDK, @modelcontextprotocol/server, serves both the new stateless protocol and older MCP clients from a single handler.

The function answers three kinds of requests. First, a public discovery document that tells MCP clients which OAuth2 server protects this MCP server. Second, a 401 with a pointer to that document when a request has no valid token. Third, actual MCP traffic when the token checks out. The SDK provides a helper for each:

TypeScript
import {
createMcpHandler,
getOAuthProtectedResourceMetadataUrl,
oauthMetadataResponse,
requireBearerAuth,
} from '@modelcontextprotocol/server'
export default async ({ req, res, error }) => {
const request = toWebRequest(req)
const url = new URL(request.url)
// The public URL of this MCP server. Tokens are minted for this exact
// URL and are useless anywhere else.
const resourceServerUrl = new URL('/mcp', url.origin)
// 1. Discovery documents for MCP clients.
const discovery = oauthMetadataResponse(request, {
oauthMetadata: await getAuthServerMetadata(),
resourceServerUrl,
scopesSupported: ['openid', 'tasks.read', 'tasks.write'],
resourceName: 'TaskFlow',
})
if (discovery) return send(res, discovery)
// 2. Require a valid access token; answer 401 with discovery info if absent.
const gate = requireBearerAuth({
verifier: createVerifier(resourceServerUrl),
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl),
})
const auth = await gate(request)
if (auth instanceof Response) return send(res, auth)
// 3. Serve MCP as the verified user.
return send(res, await mcp.fetch(request, { authInfo: auth }))
}

The token check is the security core, so it deserves a close look. Access tokens from the Appwrite OAuth2 server are signed JWTs: self-contained records of who the user is, what they approved, and which server they are for. Your function verifies them against the project's public keys, with no network call to Appwrite on the request path:

TypeScript
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 },
}
},
}
}

Three lines here do the heavy lifting. issuer confirms the token came from your project. audience confirms it was minted for this MCP server and not some other API, which works because the authorization request carried the server's URL as its resource. And typ: 'at+jwt' rejects identity tokens, which the same server signs with the same keys but which were never meant to grant API access.

With identity settled, the tools are ordinary application code. Each one checks the scopes the user approved, then works with the database as that user:

TypeScript
import { McpServer } from '@modelcontextprotocol/server'
import * as z from 'zod'
const mcp = createMcpHandler(
({ authInfo }) => {
const session = {
userId: authInfo.extra.userId,
scopes: authInfo.scopes,
apiKey: authInfo.extra.apiKey,
}
return buildServer(session)
},
// Appwrite Functions return one response per request, so answer with
// plain JSON instead of a stream.
{ responseMode: 'json' },
)
function buildServer(session) {
const server = new McpServer({ name: 'taskflow', version: '1.0.0' }, { capabilities: { tools: {} } })
server.registerTool(
'create_task',
{
title: 'Create task',
description: "Add a new task to the user's TaskFlow list.",
inputSchema: z.object({ title: z.string().min(1).max(256) }),
},
async ({ title }) => {
const denied = requireScope(session, 'tasks.write')
if (denied) return denied
const task = await createTask(session.apiKey, session.userId, title)
return json({ task })
},
)
// list_tasks and complete_task follow the same pattern.
return server
}

The scope check returns a readable error instead of throwing, so the AI can explain the situation to the user:

TypeScript
function requireScope(session, scope) {
if (session.scopes.includes(scope)) return undefined
return {
isError: true,
content: [{
type: 'text',
text: `This connection was not granted the "${scope}" permission. Ask the user to reconnect TaskFlow and approve it.`,
}],
}
}

For database access, the function uses the dynamic key Appwrite injects into every execution, filtered to the token's userId. The full data layer is in the repository.

Deploy to Appwrite Functions

The deployed MCP function in the Appwrite Console
The deployed MCP function in the Appwrite Console

The function and the web app are described in appwrite.config.json at the repository root. The scopes array is what the dynamic key inside the function can do, which for this server is reading and writing rows and nothing else:

JSON
{
"projectId": "<PROJECT_ID>",
"sites": [
{
"$id": "taskflow",
"name": "TaskFlow",
"framework": "tanstack-start",
"adapter": "ssr",
"buildRuntime": "node-22",
"installCommand": "npm install",
"buildCommand": "npm run build",
"outputDirectory": "./.output",
"path": "apps/taskflow",
"specification": "s-0.5vcpu-512mb"
}
],
"functions": [
{
"$id": "taskflow-mcp",
"name": "TaskFlow MCP",
"runtime": "node-22",
"execute": ["any"],
"scopes": ["rows.read", "rows.write"],
"entrypoint": "dist/main.js",
"commands": "npm install && npm run build",
"path": "functions/taskflow-mcp",
"specification": "s-0.5vcpu-512mb"
}
]
}

The TaskFlow web app deploys the same way, as an Appwrite Site with the TanStack Start framework preset, so the whole product runs on one platform. Both are described in the same configuration file. Deploy them with the Appwrite CLI:

Bash
appwrite login
appwrite push site --site-id taskflow
appwrite push function --function-id taskflow-mcp --activate

Before the site can run, give it its configuration in the Console under the site's Variables tab: APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID, APPWRITE_API_KEY, and a random SESSION_SECRET for its cookie. The MCP function needs no variables at all, because Appwrite injects the endpoint, project ID, and a request-scoped key into every execution.

Each deploy prints its domain, and you can assign friendlier ones in the Console under Domains. Our demo runs the app at taskflow.appwrite.network and the MCP server at taskflow-mcp.appwrite.network. With the app deployed, go back to Auth, then OAuth2 server, and update the authorization URL to the deployed consent page, https://taskflow.appwrite.network/oauth/consent.

Your MCP server now lives at https://taskflow-mcp.appwrite.network/mcp, and its discovery document is public:

JSON
{
"resource": "https://taskflow-mcp.appwrite.network/mcp",
"authorization_servers": ["https://fra.cloud.appwrite.io/v1/oauth2/6a85d86f00183ced692f"],
"scopes_supported": ["openid", "tasks.read", "tasks.write"],
"resource_name": "TaskFlow"
}

That document is the handshake that makes everything automatic: any MCP client that finds your server can read it, discover your project's OAuth2 server, and start the sign-in flow without any configuration from you or the user.

Connect Claude Code

Time to use it. Add the server, using your function's domain (the command below points at our live demo, so you can run it as-is):

Bash
claude mcp add --transport http taskflow https://taskflow-mcp.appwrite.network/mcp

Inside Claude Code, run /mcp, select taskflow, and choose Authenticate. Watch what happens: a browser opens on your project's authorization page, which sends you to TaskFlow's consent screen. Sign in as a TaskFlow user, review the three permissions, and allow access. The terminal reports the connection, and no one ever touched a client ID or an API key.

Now ask for something:

Plain text
> List my TaskFlow tasks, then add tasks 'Write the MCP tutorial' and
'Ship the TaskFlow demo', then mark the one about the demo as done.
⏺ Called taskflow 3 times
⏺ Done. List was empty. Added two tasks, marked demo done:
- ☐ Write the MCP tutorial
- ☑ Ship the TaskFlow demo

Open the TaskFlow dashboard and the changes are there, created through the same database rules as tasks added in the app, owned by the same user.

Let users take access back

TaskFlow's connected apps page showing the Claude Code connection
TaskFlow's connected apps page showing the Claude Code connection

Every approval is stored as a consent on the user's account, and the Account API exposes them to your app. TaskFlow's connected apps page is a list and a delete button:

TypeScript
const account = new Account(sessionClient)
// The user's active approvals: which client, which scopes, which server.
const { consents } = await account.listConsents()
// Disconnect one. The tool can no longer renew its access, and its current
// token ends at expiry, within an hour for AI tools.
await account.deleteConsent({ consentId })

This page is worth building on day one. Users will connect tools you have never heard of, and being able to see and sever those connections in your product is what makes self-serve access feel safe rather than scary.

What you have now

A task manager whose users can connect any MCP-capable AI tool themselves: no developer coordination, no shared API keys, no auth code beyond a consent page and a token check. The pieces you built are small and reusable:

  • The OAuth2 server came with your Appwrite project. You enabled it, named two scopes, and pointed it at a consent page.
  • The consent screen is a page in your own app: one call to load a grant, one call to approve or reject it.
  • The MCP server is an Appwrite Function that verifies signed tokens with your project's public keys and checks scopes before touching data.
  • Users manage the whole relationship from inside your product.

The same structure extends to any product: swap the tasks table for your own data, rename the scopes, and rewrite the tools. The complete project, including the TaskFlow web app, is on GitHub, and the live demo is waiting at taskflow.appwrite.network.

More resources

Read next

Ready to build?_