Skip to main content
Glama
Comet32

remote-mcp-starter

by Comet32
README.md
# remote-mcp-starter

[![CI](https://github.com/Comet32/remote-mcp-starter/actions/workflows/ci.yml/badge.svg)](https://github.com/Comet32/remote-mcp-starter/actions/workflows/ci.yml)

A production-minded starting point for **remote MCP servers that wrap a SaaS API** (Jobber, FreshBooks, Toggl, ...).

Most community MCP servers are local `stdio` processes that ask the user to paste an API key. Business users can't install those, and their admins won't allow them. This starter gives you what a hosted integration needs, so each new integration is mostly tool definitions:

- **Streamable HTTP, stateless.** A fresh server per request, so any replica can serve any request.
- **OAuth 2.1 broker.** The MCP client logs in through *your* server, which delegates the login to the upstream SaaS. Upstream tokens stay server-side, encrypted at rest; MCP clients only see tokens you issued, bound to this server (RFC 8707 audience).
- **Client registration both ways.** Client ID Metadata Documents (CIMD) and Dynamic Client Registration.
- **Safety layer on every tool.**
  - Writes default to **dry-run** and return the planned change first.
  - **Read-only mode** hides write tools entirely.
  - `mcp:read` / `mcp:write` **scopes**.
  - Correct `readOnlyHint` / `destructiveHint` annotations.
- **Audit log.** One JSON line per tool call: who, which tool, outcome, duration, arguments with secrets redacted.
- **Mock upstream.** A built-in fake SaaS (OAuth login + notes API), so the whole flow runs locally and in tests with no real account.

Targets MCP protocol **2025-11-25**, the latest supported by `@modelcontextprotocol/sdk` 1.32.

## How the login works

```
MCP client            this server                         upstream SaaS
    │  /authorize (PKCE) ──▶│                                     │
    │                       │── redirect to upstream login ──────▶│ user approves
    │                       │◀── /oauth/callback?code ────────────│
    │                       │  exchange code, keep upstream tokens (sealed)
    │◀── redirect ?code ────│
    │  /token ─────────────▶│  issue our own access + refresh token
    │  POST /mcp (Bearer) ─▶│  tool → upstream API with the user's upstream token
    │                       │         (refreshed automatically when close to expiry)
```

## Quick start

```bash
npm install
npm run dev          # http://localhost:3000/mcp, using the mock upstream
```

Connect from Claude Code. Your browser opens the mock login page; click **Approve**:

```bash
claude mcp add --transport http demo http://localhost:3000/mcp
```

Then try: *"List my notes"*, *"Create a note titled Hello"* (you get a dry-run preview first), *"OK, apply it"*.

## Adding tools

Tools live in `src/tools/`. `notes.ts` is the reference:

```ts
export const deleteNote = defineTool({
  kind: 'write',                 // 'read' | 'write'
  name: 'delete_note',
  title: 'Delete note',
  description: 'Permanently delete a note by id. Use list_notes to find the id first.',
  destructive: true,
  idempotent: true,
  input: { id: z.string() },
  // What would change. May read upstream, must not write.
  preview: async ({ id }, { upstream }) => ({ action: 'delete', note: await upstream.request(`notes/${encodeURIComponent(id)}`) }),
  run: async ({ id }, { upstream }) => {
    await upstream.request(`notes/${encodeURIComponent(id)}`, { method: 'DELETE' });
    return { deleted: id };
  },
});
```

The framework (`src/tools/framework.ts`) adds the `dryRun` parameter, scope checks, annotations, error handling and audit records. Write `preview` and `run`; nothing else.

Tips for good tools:

- Design around user tasks ("quote from a request"), not one tool per endpoint.
- Write descriptions for the model: when to use the tool, what it returns.
- Keep outputs small.

## Pointing at a real SaaS

1. Register an OAuth app with the vendor. Set its redirect URI to `https://<your-host>/oauth/callback`.
2. Set the `UPSTREAM_*` variables (see `.env.example`), and set `MOCK_UPSTREAM=false`.
3. Replace `identify` in `src/app.ts` with the vendor's "who am I" call (for Jobber, a GraphQL `account { id name }` query).
4. If the vendor's OAuth deviates from the standard (non-JSON token response, PKCE required, etc.), implement `UpstreamProvider` (`src/upstream/types.ts`) yourself.

## Configuration

| Variable | Default | Purpose |
|---|---|---|
| `PUBLIC_URL` | `http://localhost:3000` | Issuer and resource base. Must be `https` outside localhost |
| `ALLOWED_HOSTS` | PUBLIC_URL host | Host header allowlist (DNS rebinding protection) |
| `READ_ONLY` | `false` | Hide all write tools |
| `DRY_RUN_DEFAULT` | `true` | `dryRun` value when the model omits it |
| `AUDIT_LOG_PATH` | stdout | JSON-lines audit file |
| `TOKEN_ENCRYPTION_KEY` | ephemeral | 32-byte base64 key for upstream tokens at rest |
| `ACCESS_TOKEN_TTL` / `REFRESH_TOKEN_TTL` | 1h / 30d | Lifetimes of tokens issued to MCP clients |
| `CIMD_ALLOW_INSECURE` | `false` | Allow `http://` and private-network CIMD URLs (tests only) |
| `UPSTREAM_*` | mock | Upstream OAuth app and API base |
| `MOCK_UPSTREAM` | `true` | Mount the fake upstream at `/mock-upstream` |

## Production checklist

- [ ] `MOCK_UPSTREAM=false`; a persistent `TOKEN_ENCRYPTION_KEY` from a secret manager
- [ ] Replace `MemoryStore` with Redis or similar (implement `KVStore` in `src/auth/store.ts`). Required for restarts and for more than one replica
- [ ] Terminate TLS in front; set `PUBLIC_URL` and `ALLOWED_HOSTS` to the public hostname
- [ ] Behind a proxy, set Express `trust proxy` so the SDK's rate limiting sees real client IPs
- [ ] Ship the audit log somewhere durable

## Development

```bash
npm test             # end-to-end tests: OAuth flow, CIMD, scopes, dry-run, read-only, refresh, revocation
npm run typecheck
npm run lint
npm run build && npm start
```

## License

MIT © Rowan Zhao. See [LICENSE](LICENSE).