Skip to main content
Glama
anoopmkmk

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.