From 6bf4cfd069c91b0649e7d121f2b4f185735ac8d9 Mon Sep 17 00:00:00 2001 From: Garfield Date: Mon, 11 May 2026 10:39:24 -0400 Subject: [PATCH] Support Codex OAuth login and add CLI setup docs --- AGENTS_CLI_SETUP.md | 162 ++++++++++++++++++++++++++++++++++ CODEX_SETUP.md | 58 ++++++++++++ README.md | 210 +++++++++++++++++++++++--------------------- src/db.ts | 31 +++++++ src/index.ts | 50 +++++++++-- src/oauth.ts | 60 +++++++++++-- 6 files changed, 458 insertions(+), 113 deletions(-) create mode 100644 AGENTS_CLI_SETUP.md create mode 100644 CODEX_SETUP.md diff --git a/AGENTS_CLI_SETUP.md b/AGENTS_CLI_SETUP.md new file mode 100644 index 0000000..be65764 --- /dev/null +++ b/AGENTS_CLI_SETUP.md @@ -0,0 +1,162 @@ +# Connecting Hermes MCP to CLI Agents + +Hermes exposes a Streamable HTTP MCP endpoint at: + +```text +https://hermes.squaremcp.com/mcp +``` + +Legacy SSE is also available at: + +```text +https://hermes.squaremcp.com/sse +``` + +Use your own credential value anywhere you see: + +```text +YOUR_MCP_API_KEY +``` + +Never commit a live API key into repo config files. + +--- + +## Claude Code + +Some Claude Code builds support MCP add/list commands and some require manual config. + +### If your Claude CLI supports `mcp add` + +```bash +claude mcp add hermes \ + --transport http \ + https://hermes.squaremcp.com/mcp \ + --header "x-api-key: YOUR_MCP_API_KEY" +``` + +### Manual config + +If your version does not support the CLI flow, add Hermes to: + +```text +~/.claude/mcp.json +``` + +```json +{ + "mcpServers": { + "hermes": { + "url": "https://hermes.squaremcp.com/mcp", + "headers": { + "x-api-key": "YOUR_MCP_API_KEY" + } + } + } +} +``` + +### Verify + +```bash +claude mcp list +``` + +--- + +## Claude Desktop + +Add Hermes to the Claude Desktop config: + +### macOS + +```text +~/Library/Application Support/Claude/claude_desktop_config.json +``` + +### Linux + +```text +~/.config/Claude/claude_desktop_config.json +``` + +### Windows + +```text +%APPDATA%\Claude\claude_desktop_config.json +``` + +```json +{ + "mcpServers": { + "hermes": { + "url": "https://hermes.squaremcp.com/mcp", + "headers": { + "x-api-key": "YOUR_MCP_API_KEY" + } + } + } +} +``` + +Restart Claude Desktop after saving the file. + +--- + +## Generic MCP-capable CLI agents + +Many agent CLIs and editor plugins accept a JSON config file with an `mcpServers` block. + +Example portable config: + +```json +{ + "mcpServers": { + "hermes": { + "url": "https://hermes.squaremcp.com/mcp", + "headers": { + "x-api-key": "YOUR_MCP_API_KEY" + } + } + } +} +``` + +Typical usage pattern: + +```bash +some-agent-cli --mcp-config-file ~/.mcp.json +``` + +Possible target clients include: + +1. custom MCP wrappers +2. editor plugins that expose MCP server config +3. internal agent CLIs that consume a shared JSON config + +--- + +## SSE fallback + +If a client only supports SSE: + +```json +{ + "mcpServers": { + "hermes": { + "url": "https://hermes.squaremcp.com/sse", + "headers": { + "x-api-key": "YOUR_MCP_API_KEY" + } + } + } +} +``` + +--- + +## Related docs + +1. [Codex CLI setup](./CODEX_SETUP.md) +2. [opencode setup](./OPENCODE.md) +3. [ChatGPT setup](./CHATGPT_SETUP.md) diff --git a/CODEX_SETUP.md b/CODEX_SETUP.md new file mode 100644 index 0000000..9ca4149 --- /dev/null +++ b/CODEX_SETUP.md @@ -0,0 +1,58 @@ +# Connecting Hermes MCP to Codex CLI + +Hermes is exposed for Codex at: + +```text +https://hermes.squaremcp.com/mcp +``` + +## Recommended setup + +Add this block to your global Codex config at: + +```text +~/.codex/config.toml +``` + +```toml +[mcp_servers.hermes] +url = "https://hermes.squaremcp.com/mcp" +``` + +If the file already exists, append or merge the block instead of replacing your other config. + +## Example + +```toml +[projects."/path/to/your/project"] +trust_level = "trusted" + +[mcp_servers.hermes] +url = "https://hermes.squaremcp.com/mcp" +``` + +## Auth model + +Hermes supports: + +1. `x-api-key` +2. `?key=` +3. OAuth bearer tokens + +Codex config currently uses the server URL block above. If your Codex build later exposes MCP header configuration directly, use your own live credential and do not commit it into this repo. + +## Verify + +After updating `~/.codex/config.toml`, restart Codex and check that the `hermes` MCP server is available in the tool space for the project. + +## Current production endpoint + +```text +https://hermes.squaremcp.com/mcp +``` + +## Related docs + +1. [CLI agent setup](./AGENTS_CLI_SETUP.md) +2. [opencode setup](./OPENCODE.md) +3. [ChatGPT setup](./CHATGPT_SETUP.md) diff --git a/README.md b/README.md index d582e39..3e09cca 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,141 @@ # Hermes MCP -A multi-account email MCP server for [Claude AI](https://claude.ai). -Supports **Yahoo Mail** (IMAP App Password) and any **self-hosted mail server** (IMAP/SMTP). +Hermes MCP is a hosted MCP gateway for messaging, knowledge, and social connectors. + +The production endpoint is: + +```text +https://hermes.squaremcp.com/mcp +``` + +Hermes currently supports MCP access patterns for: + +1. email +2. Obsidian vault notes +3. WhatsApp Business +4. LinkedIn +5. Telegram +6. additional social connectors that are in various states of credentialing and rollout --- -## Features +## What Hermes is for -- Read, search, and send email from Claude via MCP -- Multi-account support: Yahoo Mail and custom IMAP/SMTP servers -- Streamable HTTP transport (MCP 1.x) + legacy SSE endpoint -- Automatic session recovery after server restarts -- Docker and Kubernetes deployment ready +Hermes is the integration and connector layer behind broader product work such as SquareMCP. + +Use Hermes when you want: + +1. an MCP endpoint that exposes real tools over Streamable HTTP +2. API-key or OAuth-protected access +3. connector access from agent clients such as Codex CLI, Claude Code, opencode, or ChatGPT --- -## Tools +## Core transports -| Tool | Description | Key params | -|------|-------------|------------| -| `get_profile` | Get email address for an account | `account` | -| `search_messages` | Search INBOX by keyword / sender / subject | `q`, `maxResults`, `account` | -| `read_message` | Read full message body by UID | `uid`, `account` | -| `list_folders` | List all mailbox folders | `account` | -| `create_draft` | Save a draft to the Drafts folder | `to`, `subject`, `body`, `account` | -| `send_email` | Send an email | `to`, `subject`, `body`, `account` | - -`account` parameter allows you to specify which configured mailbox to use (defaults to `"yahoo"`). +| Transport | URL | +|----------|-----| +| Streamable HTTP (preferred) | `https://hermes.squaremcp.com/mcp` | +| Legacy SSE | `https://hermes.squaremcp.com/sse` | --- -## Quick Start (local dev) +## Authentication + +Hermes accepts: + +1. `x-api-key` header +2. `?key=` query parameter +3. `Authorization: Bearer ...` for OAuth-based clients + +For local/manual config examples in this repo, always substitute your own value for: + +```text +YOUR_MCP_API_KEY +``` + +Do not commit live API keys into repo config files. + +--- + +## Client setup guides + +Use the setup guide that matches your client: + +1. [Codex CLI setup](./CODEX_SETUP.md) +2. [CLI agent setup (Claude Code, generic MCP CLIs, Claude Desktop)](./AGENTS_CLI_SETUP.md) +3. [opencode setup](./OPENCODE.md) +4. [ChatGPT Custom GPT setup](./CHATGPT_SETUP.md) + +--- + +## Codex quick setup + +Add this block to `~/.codex/config.toml`: + +```toml +[mcp_servers.hermes] +url = "https://hermes.squaremcp.com/mcp" +``` + +See [CODEX_SETUP.md](./CODEX_SETUP.md) for the full notes and caveats. + +--- + +## opencode quick setup + +Project-level `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "hermes": { + "type": "remote", + "url": "https://hermes.squaremcp.com/mcp", + "headers": { + "x-api-key": "YOUR_MCP_API_KEY" + } + } + } +} +``` + +Full instructions: [OPENCODE.md](./OPENCODE.md) + +--- + +## Local development ```bash -# 1. Install dependencies npm install - -# 2. Configure credentials cp .env.example .env -# Edit .env with your email credentials - -# 3. Run in dev mode npm run dev - -# 4. Verify curl http://localhost:3456/health -# → {"status":"ok","service":"hermes-mcp"} ``` ---- - -## Configuration - -Copy `.env.example` to `.env` and fill in your values: - -```env -# Yahoo Mail — generate an App Password at: -# https://myaccount.yahoo.com/security → App passwords -YAHOO_EMAIL=you@yahoo.com -YAHOO_APP_PASSWORD=xxxx xxxx xxxx xxxx - -# Optional: Self-hosted mail server (any IMAP/SMTP server) -# CUSTOM_EMAIL=you@yourdomain.com -# CUSTOM_PASSWORD=yourpassword -# CUSTOM_IMAP_HOST=mail.yourdomain.com -# CUSTOM_IMAP_PORT=993 -# CUSTOM_SMTP_HOST=mail.yourdomain.com -# CUSTOM_SMTP_PORT=587 - -PORT=3456 -``` +The local server runs on port `3456` by default. --- -## Connecting to Claude.ai +## Deployment -1. Go to **Claude.ai → Settings → Connectors → Add custom connector** -2. Enter your server URL: `https://your-domain.com/mcp` -3. Click **Connect** +Production deployment notes are in: + +1. [DEPLOY.md](./DEPLOY.md) +2. `hermes-k8s.yaml` + +SquareMCP product-site docs live under: + +1. [`product/site`](./product/site) +2. [`product/README.md`](./product/README.md) --- -## Production Deployment (Kubernetes) +## Notes -See [`DEPLOY.md`](./DEPLOY.md) for full instructions covering: +The historical docs in this repo started from an email-only Claude-focused setup. Current deployment and setup guidance should follow: -- MicroK8s setup with nginx-ingress and cert-manager -- Building and pushing a Docker image to the local registry -- Applying the Kubernetes Deployment / Service / Ingress manifests -- Zero-downtime redeploys after code changes - ---- - -## Architecture - -``` -Claude.ai ──POST /mcp──► StreamableHTTPServerTransport - │ - ┌─────────▼──────────┐ - │ MCP Server (SDK) │ - │ tools / handlers │ - └──┬──────────────┬───┘ - │ │ - imapflow (IMAP) nodemailer (SMTP) - │ │ - ┌────────▼───┐ ┌───────▼───────┐ - │ Yahoo Mail │ │ Custom IMAP/ │ - │ │ │ SMTP Server │ - │ │ │ (optional) │ - └────────────┘ └───────────────┘ -``` - ---- - -## Tech Stack - -- **Runtime:** Node.js + TypeScript -- **MCP SDK:** `@modelcontextprotocol/sdk` -- **IMAP:** `imapflow` -- **SMTP:** `nodemailer` -- **HTTP:** `express` -- **Deployment:** Docker + MicroK8s - ---- - -## License - -MIT +1. the `hermes.squaremcp.com` domain +2. Streamable HTTP `/mcp` as the default transport +3. the dedicated client setup docs linked above diff --git a/src/db.ts b/src/db.ts index f6a57d7..3c70244 100644 --- a/src/db.ts +++ b/src/db.ts @@ -7,6 +7,30 @@ const password = process.env.MYSQL_PASSWORD || ''; let pool: mysql.Pool | null = null; +async function ensureColumn( + db: mysql.PoolConnection, + tableName: string, + columnName: string, + definition: string +): Promise { + const [rows] = await db.execute( + ` + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = ? + `, + [tableName, columnName] + ); + + if (Array.isArray(rows) && rows.length > 0) { + return; + } + + await db.execute(`ALTER TABLE \`${tableName}\` ADD COLUMN \`${columnName}\` ${definition}`); +} + export function getPool(): mysql.Pool { if (!pool) { throw new Error('Database pool not initialized. Call initDatabase() first.'); @@ -57,12 +81,19 @@ export async function initDatabase(): Promise { code VARCHAR(255) PRIMARY KEY, client_id VARCHAR(255), redirect_uri TEXT, + scope TEXT NULL, + code_challenge TEXT NULL, + code_challenge_method VARCHAR(20) NULL, expires_at TIMESTAMP, used BOOLEAN DEFAULT FALSE, INDEX idx_expires (expires_at) ) `); + await ensureColumn(db, 'oauth_auth_codes', 'scope', 'TEXT NULL'); + await ensureColumn(db, 'oauth_auth_codes', 'code_challenge', 'TEXT NULL'); + await ensureColumn(db, 'oauth_auth_codes', 'code_challenge_method', 'VARCHAR(20) NULL'); + await db.execute(` CREATE TABLE IF NOT EXISTS oauth_tokens ( token VARCHAR(255) PRIMARY KEY, diff --git a/src/index.ts b/src/index.ts index e469c20..f7a032e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -165,6 +165,28 @@ function extractBearerToken(req: express.Request): string | undefined { return undefined; } +function extractBasicClientCredentials(req: express.Request): { clientId?: string; clientSecret?: string } { + const authHeader = req.headers.authorization as string | undefined; + if (!authHeader || !authHeader.startsWith('Basic ')) { + return {}; + } + + try { + const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); + const separatorIndex = decoded.indexOf(':'); + if (separatorIndex === -1) { + return {}; + } + + return { + clientId: decoded.slice(0, separatorIndex), + clientSecret: decoded.slice(separatorIndex + 1), + }; + } catch { + return {}; + } +} + async function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) { try { // No API key configured = open access @@ -236,6 +258,8 @@ app.get('/oauth/authorize', async (req, res) => { const redirectUri = req.query.redirect_uri as string | undefined; const state = req.query.state as string | undefined; const scope = req.query.scope as string | undefined; + const codeChallenge = req.query.code_challenge as string | undefined; + const codeChallengeMethod = req.query.code_challenge_method as string | undefined; const responseType = req.query.response_type as string | undefined; if (!clientId || !redirectUri) { @@ -254,7 +278,14 @@ app.get('/oauth/authorize', async (req, res) => { } res.setHeader('Content-Type', 'text/html'); - res.send(getAuthorizeHtml({ client_id: clientId, redirect_uri: redirectUri, state, scope })); + res.send(getAuthorizeHtml({ + client_id: clientId, + redirect_uri: redirectUri, + state, + scope, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + })); }); app.post('/oauth/authorize', async (req, res) => { @@ -262,6 +293,8 @@ app.post('/oauth/authorize', async (req, res) => { const redirectUri = req.body.redirect_uri as string | undefined; const state = req.body.state as string | undefined; const scope = req.body.scope as string | undefined; + const codeChallenge = req.body.code_challenge as string | undefined; + const codeChallengeMethod = req.body.code_challenge_method as string | undefined; const action = req.body.action as string | undefined; if (!clientId || !redirectUri) { @@ -284,7 +317,7 @@ app.post('/oauth/authorize', async (req, res) => { return; } - const code = await createAuthCode(clientId, redirectUri, scope); + const code = await createAuthCode(clientId, redirectUri, scope, codeChallenge, codeChallengeMethod); const url = new URL(redirectUri); url.searchParams.set('code', code.code); if (state) url.searchParams.set('state', state); @@ -299,17 +332,19 @@ app.post('/oauth/token', async (req, res) => { return; } - const clientId = req.body.client_id as string | undefined; - const clientSecret = req.body.client_secret as string | undefined; + const basicAuth = extractBasicClientCredentials(req); + const clientId = (req.body.client_id as string | undefined) || basicAuth.clientId; + const clientSecret = (req.body.client_secret as string | undefined) || basicAuth.clientSecret; const code = req.body.code as string | undefined; const redirectUri = req.body.redirect_uri as string | undefined; + const codeVerifier = req.body.code_verifier as string | undefined; - if (!clientId || !clientSecret || !code || !redirectUri) { + if (!clientId || !code || !redirectUri || (!clientSecret && !codeVerifier)) { res.status(400).json({ error: 'invalid_request' }); return; } - const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri); + const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri, codeVerifier); if (!token) { res.status(400).json({ error: 'invalid_grant' }); return; @@ -973,7 +1008,8 @@ const oauthDiscovery = { registration_endpoint: `${SERVER_URL}/oauth/register`, response_types_supported: ['code'], grant_types_supported: ['authorization_code'], - token_endpoint_auth_methods_supported: ['client_secret_post'], + token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'none'], + code_challenge_methods_supported: ['S256', 'plain'], }; const protectedResourceMetadata = { diff --git a/src/oauth.ts b/src/oauth.ts index eca7168..6d4c24b 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -18,6 +18,8 @@ interface AuthCode { client_id: string; redirect_uri: string; scope?: string; + code_challenge?: string; + code_challenge_method?: string; expires_at: number; } @@ -57,6 +59,19 @@ export function generateAccessToken(): string { return crypto.randomBytes(32).toString('hex'); } +function verifyPkce(codeVerifier: string, storedChallenge: string, method?: string): boolean { + if (!method || method === 'plain') { + return codeVerifier === storedChallenge; + } + + if (method === 'S256') { + const hashed = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); + return hashed === storedChallenge; + } + + return false; +} + export async function registerClient(body: { client_name?: string; redirect_uris?: string[]; @@ -122,20 +137,24 @@ export async function getClient(clientId: string): Promise { export async function createAuthCode( clientId: string, redirectUri: string, - scope?: string + scope?: string, + codeChallenge?: string, + codeChallengeMethod?: string ): Promise { const code: AuthCode = { code: generateAuthCode(), client_id: clientId, redirect_uri: redirectUri, scope, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, expires_at: Date.now() + AUTH_CODE_EXPIRY_MS, }; const pool = getPool(); await pool.execute( - 'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, expires_at) VALUES (?, ?, ?, ?)', - [code.code, clientId, redirectUri, new Date(code.expires_at)] + 'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + [code.code, clientId, redirectUri, scope || null, codeChallenge || null, codeChallengeMethod || null, new Date(code.expires_at)] ); console.log(`[oauth] Created auth code ${code.code.slice(0, 8)}... for client ${clientId}`); @@ -144,9 +163,10 @@ export async function createAuthCode( export async function exchangeCodeForToken( clientId: string, - clientSecret: string, + clientSecret: string | undefined, code: string, - redirectUri: string + redirectUri: string, + codeVerifier?: string ): Promise { let client: Client | undefined; try { @@ -156,7 +176,17 @@ export async function exchangeCodeForToken( return null; } - if (!client || client.client_secret !== clientSecret) { + if (!client) { + console.log('[oauth] Invalid client'); + return null; + } + + if (clientSecret) { + if (client.client_secret !== clientSecret) { + console.log('[oauth] Invalid client credentials'); + return null; + } + } else if (!codeVerifier) { console.log('[oauth] Invalid client credentials'); return null; } @@ -180,6 +210,18 @@ export async function exchangeCodeForToken( return null; } + if (authCode.code_challenge) { + if (!codeVerifier) { + console.log('[oauth] Missing code_verifier for PKCE exchange'); + return null; + } + + if (!verifyPkce(codeVerifier, authCode.code_challenge, authCode.code_challenge_method || undefined)) { + console.log('[oauth] PKCE verification failed'); + return null; + } + } + // Mark auth code as used await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]); @@ -230,8 +272,10 @@ export function getAuthorizeHtml(params: { redirect_uri: string; state?: string; scope?: string; + code_challenge?: string; + code_challenge_method?: string; }): string { - const { client_id, redirect_uri, state, scope } = params; + const { client_id, redirect_uri, state, scope, code_challenge, code_challenge_method } = params; return ` @@ -261,6 +305,8 @@ export function getAuthorizeHtml(params: { ${state ? `` : ''} ${scope ? `` : ''} + ${code_challenge ? `` : ''} + ${code_challenge_method ? `` : ''}