Skip to content

feat(mcp-server): per-user sign-in with Appwrite OAuth2 - #364

Merged
ChiragAgg5k merged 12 commits into
mainfrom
feat-mcp-oauth
Oct 5, 2026
Merged

ChiragAgg5k merged 12 commits into
mainfrom
feat-mcp-oauth

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

What

Adds MCP_AUTH_MODE=oauth to python/mcp-server: MCP clients (Claude, Cursor, MCP Inspector) sign users in through the project's Appwrite OAuth2 server, and each tool reads Appwrite as the signed-in user, so row permissions decide what it returns.

The MCP server is the app's own API. Tools are domain-specific (list_tasks) and gated on app-defined scopes (tasks.read). The access token never calls Appwrite directly and needs no project:* scope. To read as the user, the function's own key creates a 60-second user JWT (users.create_jwt).

Piece Where
RFC 9728 protected-resource metadata, and 401 + WWW-Authenticate: Bearer resource_metadata=… oauth.py, transport.py
Token check: RS256 against project JWKS, typ: at+jwt, iss, aud = function URL; runs inside the request deadline oauth.verify
Sign-in and consent page at /oauth/consent; honours prompt=login; framing denied consent.html
oauth.require_scope(ctx.headers, "tasks.read"), oauth.user_client(ctx.headers) oauth.py, example list_tasks

none and bearer modes are unchanged, and none is still the default. OAuth mode needs Appwrite Cloud's OAuth2 server (cloud#6276 is in production).

End-to-end on production (Python 3.12, MCP Inspector v2.9.0)

Setup: a tasks table with row security. Alice owns 3 rows, Bob owns 2. The project's OAuth2 server offers openid tasks.read, and the function has the users.write scope.

1. Add the function URL to the Inspector. That's the only client config.

2. Connecting gets a 401. The Inspector discovers the auth server, registers itself, and is sent to the template's consent page.

3. Alice signs in and approves openid tasks.read. It's the app's scope, not a raw Appwrite permission.

4. list_tasks as Alice returns her 3 tasks only

5. Clear the Inspector's OAuth state and sign in as Bob

6. Same server, same tool: Bob gets his 2 tasks only

7. Network log of the flow: 401 → resource metadata → AS discovery → register → token → MCP

A scripted run against the same production deployment passes 16/16, and staging also passes 16/16:

Check Result
Discovery, DCR, PKCE, resource → aud, wrong-resource and tampered tokens ✅
Alice / Bob see only their own tasks ✅
openid-only token → list_tasks refused ✅
tasks.read token → Appwrite API directly ✅ 401
All of the user's sessions deleted → list_tasks still works (user JWT) ✅
prompt=login with an existing session → fresh sign-in required before Allow ✅ (browser, staging)

Notes

  • The resource is always https://<host>, because the executor forwards plain HTTP without x-forwarded-proto. MCP_RESOURCE overrides it.
  • Missing-scope tool errors show as tool failed unless MCP_DEBUG is set, since the template hides tool error details by default.
  • Appwrite returns 404 for the RFC 8414 path-inserted discovery URLs. Clients fall back to …/oauth2/<project>/.well-known/openid-configuration.
  • After prompt=login, Cloud still issues auth_time from the grant (Approve.php). This is a Cloud follow-up.
  • New deps: PyJWT[crypto]>=2.10,<3, appwrite>=24,<25; Web SDK appwrite@^28.1.0.

Related: appwrite-labs/cloud#6311

MCP clients can sign users in through the project's Appwrite OAuth2 server, and tools call Appwrite as that user so row permissions apply.
@hansi-codes

hansi-codes Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

🟢 Tier S · Ready to merge

No concrete defects requiring changes were found in the authentication flow, transport integration, or example tool.

Adds optional per-user OAuth authentication through Appwrite, including protected-resource discovery, access-token validation, and a browser sign-in and consent page. Introduces a scoped, paginated list_tasks example that queries Appwrite using a short-lived user JWT, and documents the setup and configuration.

Verdict New comments Fixed Still open
✅ Approved 0 0 0
📂 Walkthrough · 7
File Change
python/mcp-server/README.md Documents OAuth endpoints, client setup, execution scopes, environment variables, and the task-listing tool.
python/mcp-server/requirements.txt Adds bounded PyJWT and Appwrite SDK dependencies.
python/mcp-server/src/app.py Adds the scope-gated, per-user task-listing tool with cursor pagination.
python/mcp-server/src/appwrite_mcp/auth.py Supports OAuth token verification and returns verified claims alongside authentication results.
python/mcp-server/src/appwrite_mcp/consent.html Adds browser sign-in, grant inspection, consent decisions, and fresh sign-in handling.
python/mcp-server/src/appwrite_mcp/oauth.py Implements discovery metadata, JWT validation, request-local claims, scope checks, and user clients.
python/mcp-server/src/appwrite_mcp/transport.py Serves OAuth discovery and consent routes and includes authentication within the request deadline.

Reviewed beffaf6 · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Tier D · 1 blocking finding to address. Summary

Comment thread python/mcp-server/src/appwrite_mcp/transport.py Outdated
Comment thread python/mcp-server/src/appwrite_mcp/oauth.py
Comment thread python/mcp-server/src/appwrite_mcp/oauth.py Outdated
Deny framing of the consent page so it can't be clickjacked, verify tokens off the event loop with a bounded JWKS fetch, and treat a non-string typ header as an invalid token instead of a 500.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@hansi-codes review

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · Looks good to merge. Summary

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · Looks good to merge. Summary

Comment thread python/mcp-server/src/appwrite_mcp/oauth.py Outdated
PyJWKClient's per-key cache never expires, so a key removed from the project's JWKS kept being trusted until the worker restarted; the JWK-set cache already refreshes every 300s.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

…deadline

The consent page now ends the session and requires a fresh sign-in when the grant asks for reauthentication (prompt=login or expired max_age), token verification counts against MCP_TOOL_TIMEOUT, and the Web SDK uses a compatible ^28.1.0 range.
…r via JWT

The access token now carries only the app's own scopes (e.g. tasks.read) and never calls Appwrite directly; tools check their scope and read as the signed-in user through a short-lived JWT minted with the function key, so row permissions still apply without granting project:* scopes.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@hansi-codes review

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Tier D · 1 blocking finding to address. Summary

Comment thread python/mcp-server/src/appwrite_mcp/oauth.py Outdated

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛔ Tier F · See the inline comments. Summary

Comment thread python/mcp-server/src/app.py Outdated
Tool helpers decoded the Authorization header without verification, so in none/bearer mode a forged JWT could pass require_scope and make user_client mint a real JWT for any user. The transport now hands the claims it verified to the request context, and the helpers refuse without them.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@hansi-codes review

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · Looks good to merge. Summary

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · Looks good to merge. Summary

list_rows defaults to a 25-row page, so larger task lists were silently cut off; the tool now takes limit/cursor and returns a typed page with next_cursor.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@hansi-codes review

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · Looks good to merge. Summary

Comment thread python/mcp-server/src/app.py Outdated
…ariables

They only configure the example list_tasks tool, which developers replace with their own tools.
A table with its own id column overwrote the row ID, which next_cursor relies on.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@hansi-codes review

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · Looks good to merge. Summary

Comment thread python/mcp-server/src/appwrite_mcp/oauth.py Outdated
With MCP_RESOURCE carrying a path (e.g. /mcp), the challenge pointed at /mcp/.well-known/oauth-protected-resource, which isn't served, so clients couldn't discover the auth server.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@hansi-codes review

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · Looks good to merge. Summary

Comment thread python/mcp-server/src/appwrite_mcp/auth.py Outdated
HTTP auth scheme names are case-insensitive (RFC 9110/6750), so clients sending 'bearer' were rejected without verification in oauth and bearer modes.
@ChiragAgg5k

Copy link
Copy Markdown
Member Author

@hansi-codes review

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

@ChiragAgg5k
ChiragAgg5k merged commit 7602b17 into main Oct 5, 2026
28 checks passed
@ChiragAgg5k
ChiragAgg5k deleted the feat-mcp-oauth branch October 5, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant