remote-mcp-starter
by Comet32
README.md
# remote-mcp-starter
[](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).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues