lark-mcp
by uzzikie
README.md
# lark-mcp
A FastMCP (Python) server that wraps [`lark-cli`](https://github.com/larksuite/cli) to expose
Lark/Feishu (IM, calendar, contacts, docs, drive, base, task) as MCP tools, gated by OAuth 2.1.
- `PUBLIC_BASE_URL/authorize` federates to Lark's real login (`OAuthProxy`) purely to identify
the caller by `open_id` — it does not grant any Lark API scope itself.
- Acting *as a specific person* (rather than the shared bot) is handled separately, via
`lark-cli`'s own device-flow login (`lark_login_start` / `lark_login_confirm` tools), since
`lark-cli` only trusts tokens it obtained itself.
- Each linked user gets an isolated `$HOME` on the shared PVC (`/data/homes/<open_id>`), so
`lark-cli`'s per-user token store doesn't collide across identities under one app-id.
## Deploying
1. Build the image: `docker build -t <your-registry>/lark-mcp:latest .`
2. Fill in `lark-mcp-deployment.yaml`'s `Secret` (app-id/app-secret from the Lark Developer
Console, and a signing key via `openssl rand -hex 32`) and your own hostname/TLS secret name.
3. Register `https://<your-hostname>/auth/callback` as a redirect URI for your Lark app.
4. `kubectl apply -f lark-mcp-deployment.yaml -n <namespace>`
## Using with Obot
[Obot](https://obot.ai) can act as an MCP gateway/proxy sitting between your actual client
(e.g. Hermes) and this server:
```
Hermes → Obot (Remote MCP connector) → this server (https://<your-hostname>/mcp) → Lark
```
Add this server in Obot as a **Remote** connector pointing at `https://<your-hostname>/mcp` —
not **Containerized**. Containerized means Obot runs the image itself, which doesn't fit this
server: Obot's containerized MCP servers get only a cluster-internal `ClusterIP` (no public
URL for the browser-based Lark login redirect to reach) and no persistent volume (every
restart would wipe every linked user's `lark-cli` identity and the OAuth client registrations
in `/data`). Remote avoids both problems entirely, since it just points at the
already-running, already-provisioned deployment.
### Why two separate "authenticate with Lark" steps happen
Going through Obot doesn't add extra authentication — it just makes both of this server's
*existing* auth layers visible as separate steps, which can look redundant but aren't:
1. **Outer layer — proves who's calling, nothing else.** When Obot (or any MCP client)
connects, it's redirected through `PUBLIC_BASE_URL/authorize` to a real Lark login page.
This step exists purely so the server can learn the caller's `open_id` — it deliberately
requests **zero** Lark API scopes (`extra_authorize_params={"scope": ""}` in `server.py`).
The resulting token lets Obot call this MCP server at all, but by itself grants no ability
to read or write anything in Lark.
2. **Inner layer — grants actual Lark API permissions.** Once identified, that person still
needs to run `lark_login_start` → approve in a browser → `lark_login_confirm`. This is a
second, independent OAuth device-flow login that `lark-cli` performs directly against
Lark, because `lark-cli` refuses to accept a token it didn't obtain itself. This is the
step that actually carries scopes like `base:record:create` or `drive:file:upload`, and
it's stored per person under `/data/homes/<open_id>` on the PVC.
So "I already have a valid token" after step 1 is true, but that token was never carrying any
Lark permissions to begin with — there's nothing in it for step 2 to reuse. Both are one-time
per person, not per Obot session: step 1's token is valid for 90 days
(`fastmcp_access_token_expiry_seconds` in `server.py`), and step 2's link persists on disk
until you explicitly redo it.
**One case that does require redoing step 2**: adding a new scope to the Lark app in the
Developer Console (and publishing a new app version, which some scopes require) does not
retroactively apply to a token issued before that change. Refreshing an existing token, or
Obot silently reconnecting with its existing session, does not pick up the new scope — only
a fresh `lark_login_start`/`lark_login_confirm` does, since that's the step that actually
requests scopes from Lark.
## Using with Amazon Quick Desktop
Quick Desktop's **Remote** connection type (native HTTP + OAuth) has a track record of failing
against otherwise spec-compliant MCP servers with a bare `401` — several confirmed cases in
AWS's own Quick community forum, independent of this server. The reliable path is Quick's
**Local** connection type via [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as a
stdio bridge: `mcp-remote` performs the full browser-based OAuth handshake itself, and Quick
just talks to it over stdio, never touching OAuth directly.
In Quick Desktop: **Settings → Capabilities → MCP Servers → Add → Local**
| Field | Value |
|---|---|
| Name | `lark-mcp` |
| Command | `npx` |
| Args | `-y mcp-remote@latest https://<your-hostname>/mcp --auth-timeout 300` |
`--auth-timeout 300` matters: the default is 30 seconds, which isn't enough time to click
through the consent screen and log into Lark interactively — the flow will otherwise fail
right at the final redirect with "site can't be reached," even though everything up to that
point worked.
The first tool call opens a real browser window to approve access once; `mcp-remote` caches
the resulting token in `~/.mcp-auth/` afterward.
### Known gotcha: stale local port on re-auth
`mcp-remote` reuses the same local callback port (e.g. `9210`) across runs. If Quick Desktop
doesn't cleanly kill a previous `mcp-remote` process before spawning a new one for re-auth,
the new one crashes with `EADDRINUSE` — the browser still shows "Authorization successful"
(caught by the old, orphaned process), but Quick never receives a working credential from its
own new attempt and just waits indefinitely.
Fix: find and kill the stale listener, then retry.
```bash
lsof -i :9210 # note the PID in LISTEN state
kill -9 <PID>
```
# Amazon Quick / Amazon Q Developer has a known bug in its OAuth state machine when handling re-authentication on expired tokens:
It spawns multiple overlapping authorization states (visible in the logs as duplicate /authorize requests with different state IDs).
When the browser sends the callback to localhost:9210, the local HTTP handler responds with success HTML to the browser, but fails to dispatch the event to the waiting MCP client thread.
## What the logs show
Looking directly at the Kubernetes pod logs during your re-auth attempt:
1. MCP Server initiated the flow correctly:
- Server returned 401 Unauthorized when the token expired.
- Amazon Quick requested GET /authorize?...&redirect_uri=http://localhost:9210/oauth/callback...
- Lark authorization completed and returned to the server at /auth/callback.
2. MCP Server completed its handoff:
- The MCP server issued an HTTP 302 Found redirecting your browser back to Amazon Quick's local listener (http://localhost:9210/oauth/callback?code=...&state=...).
- Amazon Quick received the browser callback:
- The webpage displaying "Authorized, you can close this tab" was served by Amazon Quick's local server running on localhost:9210.
3. Amazon Quick failed to complete the exchange:
- After receiving the authorization code, Amazon Quick was supposed to send a POST /token request to https://larkmcp.uzzikie.com/token to exchange the code for the session token.
- No POST /token request was ever made to the MCP server.
4. Amazon Quick's internal connection worker hung/lost the event from its own localhost:9210 listener and remained stuck waiting in the UI.
## How to resolve it
1. Restart Amazon Quick / Reload IDE Window:
- Fully restart Amazon Quick (or in VS Code / JetBrains: Developer: Reload Window or restart the IDE).
- This kills the orphaned localhost:9210 listener and clears the stuck internal state.
2. Re-connect with a clean initial auth:
- Reconnect or click authorize after the restart. Fresh/initial authentication succeeds cleanly, unlike the in-place re-auth flow.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues