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/)
```
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues