Personal MCP Server
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
.envediting required
Tools
Mail — Gmail
Tool | Summary |
| Search Gmail with typed filters (from, to, subject, text, label, unread, date range). Returns summaries. |
| Fetch a full Gmail message by ID — headers, plain text body, HTML body. |
Mail — Custom IMAP / SMTP
Tool | Summary |
| Search a custom IMAP mailbox with the same typed filters. |
| Fetch a full message from the custom IMAP mailbox by UID. |
| Validate and stage an email draft. Returns a confirmation ID; does not send. |
| Send a previously staged email using its confirmation ID. The two-step flow prevents accidental sends. |
Slack
Tool | Summary |
| Post a message (plain text or Block Kit blocks) to a configured Slack incoming webhook. |
Soul Docs (personal knowledge base)
Tool | Summary |
| Search docs by full-text query (title, content, source) or filter by tag. Returns newest first. |
| Create or update a doc. Pass an |
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 |
| Show which services are configured (no secrets exposed). |
| Set a Turso/libSQL URL or local file path, then test the connection. Accepts |
| Generate a Google OAuth authorization URL. Optionally override client ID, secret, and redirect URI. |
| Exchange the OAuth authorization code for a refresh token and store it in the runtime config. |
| Set IMAP host, port, credentials, and mailbox — then test the connection. |
| Set SMTP host, port, credentials, and default from address — then verify the connection. |
| 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 |
|
|
|
|
| HTTP listen port |
|
| Set to |
| — | Optional. When set, requires |
|
| Comma-separated origins or |
|
| Set to |
|
|
|
Database
Variable | Default | Notes |
|
|
|
| — | Required for Turso remote databases |
| — | Sync endpoint for embedded replicas |
| — | Sync interval in ms for embedded replicas |
Gmail OAuth
Variable | Required | Notes |
| Yes | From Google Cloud Console |
| Yes | From Google Cloud Console |
| Yes | e.g. |
| — | Obtained via OAuth flow; stored at runtime by |
| — | Optional CSRF state string |
Scopes requested: gmail.readonly, gmail.send.
Custom IMAP
Variable | Default | Notes |
| — | IMAP server hostname |
|
| |
|
| TLS |
| — | Usually the full email address |
| — | App password recommended |
|
|
Custom SMTP
Variable | Default | Notes |
| — | SMTP server hostname |
|
| |
|
|
|
| — | Optional |
| — | Optional |
| — | Default sender address |
|
| Expiry for staged-but-unsent emails (24 h default, so drafts survive across sessions) |
Slack
Variable | Notes |
| Incoming webhook URL |
Getting started
Path A: .env (static config)
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 transportPath B: setup tools (runtime config)
npm install
npm run build
npm startThen, from your MCP client, call the setup_* tools in any order:
setup_database— point to your DB (or skip;file:local.dbis the default)setup_gmail_oauth_start→ open the URL →setup_gmail_oauth_completewith the codesetup_custom_mail_imap+setup_custom_mail_smtp— configure mailsetup_slack_webhook— configure Slacksetup_status— verify everything is wired up
Endpoints
Method | Path | Description |
|
| MCP Streamable HTTP — requires |
|
| Health check |
|
| Start Gmail OAuth flow (HTTP mode only) |
|
| 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 formatterKey design choices:
Shared mutable config — All services hold a reference to the same
AppConfigobject. Setup tools mutate it directly; lazy services (Gmail, IMAP, SMTP, Slack) pick up changes on the next call. OnlyDatabaseServiceneeds an explicitreconnect()since it creates the libSQL client eagerly.Audit logging — Every tool call is logged to the
audit_logtable 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=debugfor verbose service-level detail (IMAP/SMTP/Gmail requests, confirmation lifecycle).Two-step email send —
email_prepare_sendstages a draft (stored in DB with a TTL),email_confirm_sendconsumes 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_completetools where the user copies the code manually.
Database
Three tables are created automatically on startup:
Table | Purpose |
| Personal knowledge base — title, content, tags, source, metadata, timestamps |
| Staged email drafts with expiry and single-use tokens |
| Immutable record of every tool invocation |
Local vs remote
# 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-tokenPlain 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.
# 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.0Docker Compose (local build)
docker compose up --buildTo pull from GHCR instead of building locally, swap the build: . line in docker-compose.yml for:
image: ghcr.io/olamide226/personal-mcp-server:latestBare metal / pod
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.jsFor production, set a strong MCP_BEARER_TOKEN and restrict MCP_ALLOWED_ORIGINS to your client origin(s).
Image tags
Tag | When |
| Every push to |
| Every push to |
| Version tag (e.g. |
| Major.minor alias (e.g. |
| Major alias (e.g. |
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: thev*tag is pushed using the defaultGITHUB_TOKEN, and GitHub does not trigger downstream workflows fromGITHUB_TOKENactivity (to prevent loops). Soci.yml'stags: ["v*"]trigger never fires for auto-created tags — the image is built in the samerelease.ymlrun 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.0predates this workflow), run the Semantic Release workflow manually — Actions → Semantic Release → Run workflow →v0.1.0— to build and push0.1.0/0.1/0for that tag.
Semantic releases
Merging a PR to main triggers the release.yml workflow which parses conventional commits since the last tag:
Commit prefix | Version bump |
| minor |
| patch |
| 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
curl http://localhost:3000/healthz
# {"ok":true,"name":"personal-mcp-server","version":"0.1.0"}Development
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/)