Repository navigation
feat(mcp-server): per-user sign-in with Appwrite OAuth2 - #364
Merged
Merged
Conversation
MCP clients can sign users in through the project's Appwrite OAuth2 server, and tools call Appwrite as that user so row permissions apply.
🟢 Tier S · Ready to merge
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
📂 Walkthrough · 7
Reviewed |
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.
Member
Author
|
@hansi-codes review |
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.
…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.
Member
Author
|
@hansi-codes review |
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.
Member
Author
|
@hansi-codes review |
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.
Member
Author
|
@hansi-codes review |
…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.
Member
Author
|
@hansi-codes review |
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.
Member
Author
|
@hansi-codes review |
HTTP auth scheme names are case-insensitive (RFC 9110/6750), so clients sending 'bearer' were rejected without verification in oauth and bearer modes.
Member
Author
|
@hansi-codes review |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds
MCP_AUTH_MODE=oauthtopython/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 noproject:*scope. To read as the user, the function's own key creates a 60-second user JWT (users.create_jwt).401+WWW-Authenticate: Bearer resource_metadata=…oauth.py,transport.pytyp: at+jwt,iss,aud= function URL; runs inside the request deadlineoauth.verify/oauth/consent; honoursprompt=login; framing deniedconsent.htmloauth.require_scope(ctx.headers, "tasks.read"),oauth.user_client(ctx.headers)oauth.py, examplelist_tasksnoneandbearermodes are unchanged, andnoneis 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
taskstable with row security. Alice owns 3 rows, Bob owns 2. The project's OAuth2 server offersopenid tasks.read, and the function has theusers.writescope.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_tasksas Alice returns her 3 tasks only5. 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→ MCPA scripted run against the same production deployment passes 16/16, and staging also passes 16/16:
resource→aud, wrong-resource and tampered tokensopenid-only token →list_tasksrefusedtasks.readtoken → Appwrite API directlylist_tasksstill works (user JWT)prompt=loginwith an existing session → fresh sign-in required before AllowNotes
https://<host>, because the executor forwards plain HTTP withoutx-forwarded-proto.MCP_RESOURCEoverrides it.tool failedunlessMCP_DEBUGis set, since the template hides tool error details by default.…/oauth2/<project>/.well-known/openid-configuration.prompt=login, Cloud still issuesauth_timefrom the grant (Approve.php). This is a Cloud follow-up.PyJWT[crypto]>=2.10,<3,appwrite>=24,<25; Web SDKappwrite@^28.1.0.Related: appwrite-labs/cloud#6311