Sweet Assist MCP Server
by anoopmkmk
README.md
# Sweet Assist MCP Server
Standalone MCP server exposing Sweet Assist transactions, contacts, envelopes and
agent profiles to Claude, behind an OAuth 2.1 login + approve flow.
## Running locally
```bash
npm install
node index.js # http://localhost:3000
```
Expose it over HTTPS (Claude will not accept an `http://` connector URL):
```bash
ngrok http 3000 # then set SERVER_URL to the https URL it prints
```
**`SERVER_URL` in `.env` must match the public URL exactly.** It is used to build
the OAuth metadata, the authorize/token endpoints and the redirect targets. If it
still says `http://localhost:3000` while Claude reaches you over a tunnel, the
login redirect breaks.
Then in Claude: Settings → Connectors → Add custom connector → paste
`https://<your-host>/mcp`.
## The connector flow
| Step | Endpoint | What happens |
|---|---|---|
| 1 | `POST /mcp` (no token) | 401 + `WWW-Authenticate` pointing at the metadata |
| 2 | `GET /.well-known/oauth-protected-resource` | names the authorization server |
| 3 | `GET /.well-known/oauth-authorization-server` | endpoint metadata |
| 4 | `POST /register` | Claude registers itself (RFC 7591). No human step. |
| 5 | `GET /oauth/authorize` | branded Sweet Assist login page |
| 6 | `POST /oauth/login` | verifies the broker's password against `users` |
| 7 | `POST /oauth/approve` | the consent screen; redirects back with a code |
| 8 | `POST /oauth/token` | code + PKCE verifier → access token |
| 9 | `POST /mcp` (with token) | `brokerId` comes from the token, never a default |
Public client with PKCE S256 — there is no client secret. Authorization codes are
single-use and expire in 60 seconds.
## Before production
1. **Move `clients` / `codes` / `tokens` out of memory** ([oauth/store.js](oauth/store.js)).
They are `Map`s today, so a restart invalidates every connector and each broker
must re-approve. These need database tables.
2. **Add token revocation.** `revokeToken()` exists but nothing calls it. Removing
the connector in Claude should kill the token server-side.
3. **Rate-limit `POST /oauth/login`.** There is no throttling on password attempts.
4. **Tighten CORS.** `app.use(cors())` currently allows any origin.
5. **Serve over real HTTPS**, not a dev tunnel.
6. **Set a refresh token flow** — access tokens currently last 30 days with no refresh.
## Tools
`list_transactions`, `get_transaction`, `list_envelopes`, `list_contacts`,
`list_agents`, `get_my_profile` — all read-only, all scoped to the authenticated
broker and the agents beneath them via `getScopedUserIds()`.
### Transaction status
`txn_transactions.status` is an int owned by the Laravel app
(`TxnConstant::TXN_STATUS`). It is surfaced as a label via `STATUS_LABEL` in
[constants.js](constants.js) — **keep the two in step**:
| Int | Name |
|---|---|
| 1 | Active |
| 2 | Closed (`archived` in the DB) |
| 3 | Canceled |
| 4 | Expired |
| 5 | Deleted |
| 6 | Canceled, Closed — matches both the `closed` and `canceled` filters |
`status: 'all'` (the default) excludes deleted. An unrecognised status returns an
error listing the valid ones rather than silently falling back to everything.
### Transaction phase — prefer this filter
What the product calls a phase is **not one column**. Each tab a broker sees is a
combination of `phase`, `status` and `for_closed`. `PHASE_TABS` in
[constants.js](constants.js) mirrors the Laravel
app's `Livewire/Transaction/Mongos/txnTable.php` pipeline — **if those tabs change,
this must change too**:
| Tab | `phase` | `status` | `for_closed` |
|---|---|---|---|
| Active | 1 | 1 | 0 |
| Pending | 0 | 1 | 0 |
| Submitted For Closing | 1 or 0 | 1 | **1** |
| Closing | 3 | 1 | — |
| Closed | 3 | 2 | — |
| Canceled | — | 3 | — |
Note the trap: "Submitted For Closing" is **not** a `phase` value. It is
`for_closed = 1` while `phase` stays 1 or 0. Filtering on `phase` alone silently
returns the wrong rows.
`expired` (status 4) and `deleted` (status 5) have no tab of their own but are
accepted as phase names. Omitting `phase` returns everything except deleted.
**`list_transactions` no longer advertises a `status` parameter.** Offering both was
the bug: asked for "my active transactions" the model picked `status: 'active'`, which
is the raw column — and a transaction moved to Closing still has `status = 1`, so it
kept appearing under active. A `status` argument from an older client is still accepted
and routed through the same tab table, so both spellings now mean the Active tab.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues