Launching on Product Hunt today
Blog

Engineering6 min read

How we built MCP1 on Appwrite

MCP1 runs on a single Appwrite project, with its OAuth2 server for every MCP client, Teams for workspaces and one function that decides every call.

Eldad FuxFounder and CEO, Appwrite
AuthDatabasesStorageSites Built on Appwrite
AuthOAuth2 and TeamsDatabasesTablesDBStorageLogos and photosSitesHosting Built on Appwrite

Last week I wrote about how we built a one-click remote MCP server on Appwrite by mistake. MCP1 uses the same building blocks for a different job. It sits in front of every MCP server a company uses and decides, on each call, who is asking and whether they may. There’s no separate identity provider, database or host behind it. Everything MCP1 needs is a service in one Appwrite project.

ServiceUsed for
Auth
Accounts, sessions, sign-in links, recovery and two-factor sign-in
Teams
Workspaces, roles and invitations
OAuth2
Every MCP client’s authentication: registration, PKCE, consent, JWTs, revocation and devices
Databases
Gateways, groups, servers, tools, keys, clients, confirmations and the audit log, in TablesDB
Storage
Workspace logos and profile photos
Sites
The dashboard, the docs, this blog and the gateway itself, server-rendered

The rest of this post follows one tool call through the gateway, and the decisions we made on the way.

Every client authenticates with Appwrite

MCP clients expect OAuth 2.1, and they expect to discover it on their own, so the authorization server is the one part a gateway can’t skip. Appwrite Cloud has one built in. MCP1 hosts the consent screen and checks tokens, and Appwrite does everything else.

  1. 1The client calls a gateway URL, like /mcp/acme/engineering, without a token. MCP1 answers 401 with a header pointing to its protected resource metadata (RFC 9728), which names the Appwrite project as the authorization server.
  2. 2The client introduces itself. Claude uses a client ID metadata document, a URL with its name and logo, which MCP1 reads carefully: https only, public hosts only, no redirects, a short timeout and a size cap. Other clients register dynamically (RFC 7591). Either way there’s no list of clients for us to maintain.
  3. 3The human authenticates in the browser with PKCE and approves the client on MCP1’s consent screen, where they also pick the workspace.
  4. 4Appwrite issues a signed JWT access token. The client asked for the gateway’s URL as its resource (RFC 8707), so that URL is in the token’s audience.
  5. 5On every call, MCP1 verifies the token offline against the project’s published keys and opens only the workspace the token was approved for.
src/server/gateway.ts
const { payload } = await jwtVerify(token, getJwks(), {
  issuer: env.issuer,
  typ: 'at+jwt',
})
// The audience is the gateway URL the client asked for (RFC 8707).
// A token approved for /mcp/acme that shows up on /mcp/globex
// is sent back to authenticate again.

A few smaller rules sit around this. Only accounts with a verified email address can use a gateway. Revoking a client in the dashboard ends its access on its next call. Tools without a browser use the device flow, showing a code the human enters at /oauth/device. And CI uses API keys instead of tokens: mcp1_live_ followed by 40 random characters, tied to one gateway and stored only as a SHA-256 hash.

MCP1 is an OAuth client too

The other direction uses the same standards. Catalog servers like Linear, Notion and Sentry speak MCP’s own OAuth, so a one-click connection runs the flow above with MCP1 as the client: it reads the server’s RFC 9728 metadata, registers itself (RFC 7591), signs in with PKCE and stores the tokens sealed on the server’s row. A token is refreshed once it’s within a minute of expiring, so a call doesn’t go out with a token that runs out on the way.

Workspaces are Teams

Each workspace is an Appwrite Team, and its roles map one to one to membership roles: owner, admin, developer, member and auditor. Invitations, accepting them and leaving are Teams features, so we didn’t build any of them. The gateway looks up the caller’s membership as it answers and keeps it for ten seconds, so removing someone closes every client and key they had within seconds, without a token to revoke.

How a call is decided

The policy is one pure function of about 40 lines. It takes the gateway, the server, the tool, the caller and the workspace settings, and returns one of three things: allowed, allowed after a confirmation, or a reason. The order matters, so here it is, shortened:

src/server/policy.ts, shortened
function toolDecision({ gateway, server, tool, caller, settings, keyServerIds }) {
  // The gateway: yours, or granted to one of your groups, fully or read-only.
  const access = gatewayAccess(gateway, caller)
  if (!access.allowed) return access
  if (!gateway.serverIds.includes(server.id))
    return deny(`${server.name} isn’t on ${gateway.name}.`)
  if (keyServerIds?.length && !keyServerIds.includes(server.id))
    return deny(`This key isn’t scoped to ${server.name}.`)
  if (access.mode === 'read-only' && !tool.readOnly)
    return deny(`Your group has read-only access to ${gateway.name}.`)
  if (!atLeast(caller.role, tool.minRole))
    return deny(`${tool.name} needs the ${tool.minRole} role.`)
  // Then the tool's groups, the gateway's tool rules,
  // and rules by name: block, minRole, groups or confirm.
  if (settings.guardDestructive && tool.destructive && !admin)
    return deny(`${tool.name} is destructive and needs an admin.`)
  if (settings.membersReadOnly && caller.role === 'member' && !tool.readOnly)
    return deny('Members can use read-only tools only in this workspace.')
  return confirm ? { allowed: true, confirm } : ALLOW
}

Two details took us a while to get right. Each reason is a sentence written for the model, so a blocked agent can tell its human what happened instead of guessing, and the same sentence goes into the audit log. And the same function decides tools/list, so people only see the tools they can call, and the playground in the dashboard runs it too.

Read-only, twice

MCP1 learns which tools are read-only from the annotations each server gives them, readOnlyHint and destructiveHint, when it syncs the server. Annotations are hints, though, and a server can be wrong about its own tools. So a server can also hold a read-only credential, and anyone on read-only access calls with it, on a connection of their own. A comment in the code gives the reason: “a bug in MCP1’s checks can’t turn a reader into a writer.” The idea comes from our internal gateway.

Asking the human first

Ask first rules and destructive tools can ask the human before a call runs, with MCP elicitation. This was the most interesting part to build, because it doesn’t fit a stateless request and response.

When a call needs a yes, the response to the tools/call request becomes a short event stream. First comes an elicitation/create question with the tool and its arguments, then the result. The client posts the human’s answer as a separate request, and that request can land on a different instance of the site. So every question is written to a confirmations table before it’s asked, the instance holding the call checks the table once a second, and an answer counts only for a question MCP1 asked in that workspace. If nobody answers within 24 seconds the call doesn’t run, and the audit log says why.

Each client says whether it supports elicitation when it connects, and MCP1 remembers it. Clients that can’t ask are refused for Ask first rules, since running the call without asking would ignore the rule.

Keeping answers small

Some tools return a lot, and big answers make models worse. Each workspace has an answer size limit, 1 MB by default. Text over the limit is trimmed and the model gets a note asking for less: a filter, a smaller time range or a page size. Structured content that’s too large is held back whole, since half a JSON document is worse than none.

Outbound requests go through one guard

Anyone can sign up and add a server, so every request MCP1 makes to an address someone typed goes through one place: upstream servers, their OAuth metadata, token and registration endpoints, and client metadata documents. Loopback, private, link-local and reserved ranges are refused for the URL itself, for every address its name resolves to at connect time, and for every redirect on the way.

Checking at connect time is the important part. With DNS rebinding, a name resolves to a public address when you check it and to a private one when you connect, so a separate check proves nothing. MCP1 checks the address it actually connects to. A self-hosted MCP1 that should reach servers on its own network can turn this off with MCP1_ALLOW_PRIVATE_NETWORK=true.

Encryption

  • Upstream tokens, OAuth grants, call arguments and IP addresses are sealed with AES-256-GCM before they reach the database, with a random 12-byte nonce, and opening a value requires the full 16-byte tag.
  • Call arguments in the audit log are sealed for a purpose, bound in as associated data. Their text came from users, so a value sealed for one purpose can’t be passed off as another.
  • Secrets inside arguments are masked before anything is sealed, and an admin can turn off recording arguments altogether.
  • The encryption key lives only in the site’s secret variables, never in the database.

Deploying it

The audit log is the busiest table by far, with a row for every call, authentication and change. The dashboard, the docs, the server catalog, this blog and the gateway itself are one TanStack Start app, server-rendered on Appwrite Sites, so a single deploy ships all of it, and the gateway’s URL stays the same across deploys.

Trade-offs worth knowing

  • Appwrite’s OAuth2 server is on Appwrite Cloud and not in self-hosted Appwrite yet, so MCP1 needs an Appwrite Cloud project for now.
  • A call has 28 seconds at most, 25 by default. Tools that run longer need to start a job and let the agent check on it.
  • Read-only trusts each server’s annotations unless the server also holds a read-only credential.
  • Confirmations depend on clients that support elicitation. Support is growing, but it isn’t everywhere yet.

What’s next

Everything MCP1 uses is a standard Appwrite service, so if you’re building something like it, you can start on Appwrite with the same pieces. To try MCP1 itself, create a workspace, read the launch post for the story behind it, or the 10 steps for rolling it out across a company. The docs cover every setting mentioned here.

Give your team one URL

Create a workspace, install your servers and share a gateway with a group. Free for individuals.