Skip to main content
Glama
olamide226

Personal MCP Server

by olamide226
README.md
# Personal MCP Server

A private, single-user MCP (Model Context Protocol) server that gives AI agents access to your email, Slack notifications, and a personal knowledge base ("soul docs"). Written in TypeScript, runs over Streamable HTTP or stdio.

**Capabilities at a glance:**
- Search and read Gmail or any IMAP mailbox
- Send email via Gmail API or custom SMTP (with a prepare→confirm safety flow)
- Post Slack notifications via incoming webhooks
- CRUD a personal knowledge base backed by SQLite or Turso/libSQL
- Configure all credentials at runtime via MCP tools — no `.env` editing required

---

## Tools

### Mail — Gmail

| Tool | Summary |
|------|---------|
| `gmail_search_messages` | Search Gmail with typed filters (from, to, subject, text, label, unread, date range). Returns summaries. |
| `gmail_get_message` | Fetch a full Gmail message by ID — headers, plain text body, HTML body. |

### Mail — Custom IMAP / SMTP

| Tool | Summary |
|------|---------|
| `custom_mail_search_messages` | Search a custom IMAP mailbox with the same typed filters. |
| `custom_mail_get_message` | Fetch a full message from the custom IMAP mailbox by UID. |
| `email_prepare_send` | Validate and stage an email draft. Returns a confirmation ID; **does not send**. |
| `email_confirm_send` | Send a previously staged email using its confirmation ID. The two-step flow prevents accidental sends. |

### Slack

| Tool | Summary |
|------|---------|
| `send_slack_notification` | Post a message (plain text or Block Kit blocks) to a configured Slack incoming webhook. |

### Soul Docs (personal knowledge base)

| Tool | Summary |
|------|---------|
| `get_my_soul_docs` | Search docs by full-text query (title, content, source) or filter by tag. Returns newest first. |
| `write_my_soul_doc` | Create or update a doc. Pass an `id` to upsert (preserves original `created_at`); omit to create new. Each doc has a title, body content, tags, an optional source reference, and arbitrary metadata. |

### Setup (runtime configuration)

When `MCP_ENABLE_SETUP_TOOLS=true` (the default), these tools let you configure every service at runtime and test connections immediately. All changes are in-memory — they override `.env` values but reset on restart.

| Tool | Summary |
|------|---------|
| `setup_status` | Show which services are configured (no secrets exposed). |
| `setup_database` | Set a Turso/libSQL URL or local file path, then test the connection. Accepts `libsql://`, `file:`, or plain paths like `/data/db.sqlite`. |
| `setup_gmail_oauth_start` | Generate a Google OAuth authorization URL. Optionally override client ID, secret, and redirect URI. |
| `setup_gmail_oauth_complete` | Exchange the OAuth authorization code for a refresh token and store it in the runtime config. |
| `setup_custom_mail_imap` | Set IMAP host, port, credentials, and mailbox — then test the connection. |
| `setup_custom_mail_smtp` | Set SMTP host, port, credentials, and default from address — then verify the connection. |
| `setup_slack_webhook` | Set a Slack incoming webhook URL and send a test notification. |

Set `MCP_ENABLE_SETUP_TOOLS=false` to disable all setup tools and use `.env`-only configuration.

---

## Configuration reference

Every setting can be provided via `.env` or overridden at runtime by the corresponding `setup_*` tool.

### Runtime

| Variable | Default | Notes |
|----------|---------|-------|
| `MCP_TRANSPORT` | `streamable-http` | `streamable-http`, `http` (alias), or `stdio` |
| `MCP_PORT` | `3000` | HTTP listen port |
| `MCP_HOST` | `127.0.0.1` | Set to `0.0.0.0` for remote access |
| `MCP_BEARER_TOKEN` | — | Optional. When set, requires `Authorization: Bearer <token>` on all requests |
| `MCP_ALLOWED_ORIGINS` | `*` | Comma-separated origins or `*` for all |
| `MCP_ENABLE_SETUP_TOOLS` | `true` | Set to `false` to remove `setup_*` tools |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error`. Structured JSON logs go to stderr |

### Database

| Variable | Default | Notes |
|----------|---------|-------|
| `TURSO_DATABASE_URL` | `file:local.db` | `file:` for local SQLite, `libsql://` for Turso, or a plain path |
| `TURSO_AUTH_TOKEN` | — | Required for Turso remote databases |
| `TURSO_SYNC_URL` | — | Sync endpoint for embedded replicas |
| `TURSO_SYNC_INTERVAL_MS` | — | Sync interval in ms for embedded replicas |

### Gmail OAuth

| Variable | Required | Notes |
|----------|----------|-------|
| `GOOGLE_CLIENT_ID` | Yes | From Google Cloud Console |
| `GOOGLE_CLIENT_SECRET` | Yes | From Google Cloud Console |
| `GOOGLE_REDIRECT_URI` | Yes | e.g. `http://127.0.0.1:3000/oauth/google/callback` |
| `GOOGLE_REFRESH_TOKEN` | — | Obtained via OAuth flow; stored at runtime by `setup_gmail_oauth_complete` |
| `GOOGLE_OAUTH_STATE` | — | Optional CSRF state string |

Scopes requested: `gmail.readonly`, `gmail.send`.

### Custom IMAP

| Variable | Default | Notes |
|----------|---------|-------|
| `CUSTOM_IMAP_HOST` | — | IMAP server hostname |
| `CUSTOM_IMAP_PORT` | `993` | |
| `CUSTOM_IMAP_SECURE` | `true` | TLS |
| `CUSTOM_IMAP_USER` | — | Usually the full email address |
| `CUSTOM_IMAP_PASSWORD` | — | App password recommended |
| `CUSTOM_IMAP_MAILBOX` | `INBOX` | |

### Custom SMTP

| Variable | Default | Notes |
|----------|---------|-------|
| `CUSTOM_SMTP_HOST` | — | SMTP server hostname |
| `CUSTOM_SMTP_PORT` | `587` | |
| `CUSTOM_SMTP_SECURE` | `false` | `true` for port 465, `false` for 587 (STARTTLS) |
| `CUSTOM_SMTP_USER` | — | Optional |
| `CUSTOM_SMTP_PASSWORD` | — | Optional |
| `EMAIL_DEFAULT_FROM` | — | Default sender address |
| `EMAIL_CONFIRMATION_TTL_SECONDS` | `86400` | Expiry for staged-but-unsent emails (24 h default, so drafts survive across sessions) |

### Slack

| Variable | Notes |
|----------|-------|
| `SLACK_WEBHOOK_URL` | Incoming webhook URL |

---

## Getting started

### Path A: .env (static config)

```bash
npm install
cp .env.example .env
# Edit .env with your credentials
npm run build
npm start                   # HTTP on port 3000
# or: npm run start:stdio   # stdio transport
```

### Path B: setup tools (runtime config)

```bash
npm install
npm run build
npm start
```

Then, from your MCP client, call the `setup_*` tools in any order:

1. **`setup_database`** — point to your DB (or skip; `file:local.db` is the default)
2. **`setup_gmail_oauth_start`** → open the URL → **`setup_gmail_oauth_complete`** with the code
3. **`setup_custom_mail_imap`** + **`setup_custom_mail_smtp`** — configure mail
4. **`setup_slack_webhook`** — configure Slack
5. **`setup_status`** — verify everything is wired up

### Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/mcp` | MCP Streamable HTTP — requires `Accept: application/json, text/event-stream` |
| `GET` | `/healthz` | Health check |
| `GET` | `/oauth/google/start` | Start Gmail OAuth flow (HTTP mode only) |
| `GET` | `/oauth/google/callback` | Gmail OAuth callback (HTTP mode only) |

---

## Architecture

```
src/
├── config.ts          Env parsing (Zod), defaults, setConfigValue helper
├── index.ts           Entry point — loads config, creates services, starts transport
├── runtime.ts         Service factory — wires up all services with shared config
├── server.ts          MCP server — registers all tools, conditionally includes setup tools
├── setup-tools.ts     Runtime config tools — mutate config, test connections
├── tools.ts           Core tool handlers — email, Slack, soul docs
├── errors.ts          Custom error classes
├── logger.ts          Structured JSON logging
├── types.ts           Shared TypeScript interfaces
├── transports/
│   ├── http.ts        Streamable HTTP transport + OAuth callback routes
│   └── stdio.ts       Stdio transport
├── services/
│   ├── database.ts    Turso/libSQL client — soul_docs, send_confirmations, audit_log
│   ├── gmail-api.ts   Thin Gmail REST wrapper via google-auth-library + fetch (replaces googleapis)
│   ├── gmail.ts       Gmail service — search, read, send, OAuth
│   ├── custom-mail.ts IMAP (imapflow) + SMTP (nodemailer) — search, read, send
│   ├── email-sender.tsComposite — delegates to Gmail or SMTP based on provider
│   └── slack.ts       Slack incoming webhook via fetch
└── utils/
    ├── email.ts       MIME builder, base64url helpers
    └── mcp.ts         jsonText() response formatter
```

**Key design choices:**
- **Shared mutable config** — All services hold a reference to the same `AppConfig` object. Setup tools mutate it directly; lazy services (Gmail, IMAP, SMTP, Slack) pick up changes on the next call. Only `DatabaseService` needs an explicit `reconnect()` since it creates the libSQL client eagerly.
- **Audit logging** — Every tool call is logged to the `audit_log` table with success/failure, args (secrets redacted), and a timestamp.
- **Structured logging** — Every tool call (with redacted args and duration), email send, and error is also emitted as a JSON log line on stderr, so stdout stays clean for the stdio transport. Set `LOG_LEVEL=debug` for verbose service-level detail (IMAP/SMTP/Gmail requests, confirmation lifecycle).
- **Two-step email send** — `email_prepare_send` stages a draft (stored in DB with a TTL), `email_confirm_send` consumes it. Prevents accidental sends and gives the agent a chance to review.
- **OAuth works in both transports** — Streamable HTTP mode has dedicated callback routes; stdio mode uses the `setup_gmail_oauth_start` / `setup_gmail_oauth_complete` tools where the user copies the code manually.

---

## Database

Three tables are created automatically on startup:

| Table | Purpose |
|-------|---------|
| `soul_docs` | Personal knowledge base — title, content, tags, source, metadata, timestamps |
| `send_confirmations` | Staged email drafts with expiry and single-use tokens |
| `audit_log` | Immutable record of every tool invocation |

### Local vs remote

```env
# Local SQLite (relative to working directory)
TURSO_DATABASE_URL=file:local.db

# Local SQLite (absolute — for pods/containers that need persistent state)
TURSO_DATABASE_URL=file:/data/my-server.db

# Remote Turso DB
TURSO_DATABASE_URL=libsql://your-db.turso.io
TURSO_AUTH_TOKEN=your-token
```

Plain paths passed to `setup_database` are auto-prefixed with `file:` — so `/data/db.sqlite` becomes `file:/data/db.sqlite`.

---

## Deployment

### Docker (GHCR)

Pre-built multi-arch images (`linux/amd64`, `linux/arm64`) are published to GitHub Container Registry on every push to `main`.

```bash
# Pull and run
docker run -d --name personal-mcp \
  --env-file .env \
  -p 3000:3000 \
  -v "$(pwd)/data:/app/data" \
  ghcr.io/olamide226/personal-mcp-server:latest

# Or with a specific version
docker run -d --name personal-mcp \
  --env-file .env \
  -p 3000:3000 \
  -v "$(pwd)/data:/app/data" \
  ghcr.io/olamide226/personal-mcp-server:0.1.0
```

### Docker Compose (local build)

```bash
docker compose up --build
```

To pull from GHCR instead of building locally, swap the `build: .` line in `docker-compose.yml` for:
```yaml
image: ghcr.io/olamide226/personal-mcp-server:latest
```

### Bare metal / pod

```bash
npm ci --omit=dev
npm run build
MCP_HOST=0.0.0.0 MCP_PORT=3000 TURSO_DATABASE_URL=file:/data/server.db node dist/index.js
```

For production, set a strong `MCP_BEARER_TOKEN` and restrict `MCP_ALLOWED_ORIGINS` to your client origin(s).

### Image tags

| Tag | When |
|-----|------|
| `latest` | Every push to `main` |
| `<full-sha>` | Every push to `main` |
| `0.1.0` | Version tag (e.g. `v0.1.0` → `0.1.0`) |
| `0.1` | Major.minor alias (e.g. `v0.1.0` → `0.1`) |
| `0` | Major alias (e.g. `v0.1.0` → `0`) |

`latest` and `<full-sha>` are published by the `docker` job in `ci.yml` on every push to `main`. The semver tags (`0.1.0`, `0.1`, `0`) are published by the `docker` job in `release.yml` when a release tag is created — the leading `v` from the git tag is dropped on the image tags (Docker/OCI convention), so `v0.1.0` → `0.1.0`. Semantic releases start at `v0.1.0`.

> **Note — why the build lives in `release.yml`:** the `v*` tag is pushed using the default `GITHUB_TOKEN`, and GitHub does **not** trigger downstream workflows from `GITHUB_TOKEN` activity (to prevent loops). So `ci.yml`'s `tags: ["v*"]` trigger never fires for auto-created tags — the image is built in the same `release.yml` run that creates the tag instead.

> **Rebuild an existing tag's image:** if a git tag already exists but its image wasn't published (e.g. `v0.1.0` predates this workflow), run the *Semantic Release* workflow manually — **Actions → Semantic Release → Run workflow → `v0.1.0`** — to build and push `0.1.0` / `0.1` / `0` for that tag.

### Semantic releases

Merging a PR to `main` triggers the `release.yml` workflow which parses [conventional commits](https://www.conventionalcommits.org/) since the last tag:

| Commit prefix | Version bump |
|---------------|-------------|
| `feat: ...` | minor |
| `fix: ...`, `perf: ...` | patch |
| `BREAKING CHANGE: ...` or `feat!: ...` | major |

If any meaningful commits are found, a `vX.Y.Z` tag is created and the `release.yml` `docker` job builds & pushes the matching semver image tags (`X.Y.Z`, `X.Y`, `X`) to GHCR. (The tag push uses `GITHUB_TOKEN`, so `ci.yml` is not triggered by it — hence the build runs here.)

### Health check

```bash
curl http://localhost:3000/healthz
# {"ok":true,"name":"personal-mcp-server","version":"0.1.0"}
```

---

## Development

```bash
npm install
npm run dev          # tsx watch — auto-reload on changes
npm run build        # tsc
npm test             # vitest
npm run lint         # eslint
npm run typecheck    # tsc --noEmit (includes tests/)
```