Support Codex OAuth login and add CLI setup docs

This commit is contained in:
Garfield
2026-05-11 10:39:24 -04:00
parent ffb67560b9
commit 6bf4cfd069
6 changed files with 458 additions and 113 deletions

162
AGENTS_CLI_SETUP.md Normal file
View File

@@ -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)

58
CODEX_SETUP.md Normal file
View File

@@ -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)

210
README.md
View File

@@ -1,129 +1,141 @@
# Hermes MCP # Hermes MCP
A multi-account email MCP server for [Claude AI](https://claude.ai). Hermes MCP is a hosted MCP gateway for messaging, knowledge, and social connectors.
Supports **Yahoo Mail** (IMAP App Password) and any **self-hosted mail server** (IMAP/SMTP).
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 Hermes is the integration and connector layer behind broader product work such as SquareMCP.
- Multi-account support: Yahoo Mail and custom IMAP/SMTP servers
- Streamable HTTP transport (MCP 1.x) + legacy SSE endpoint Use Hermes when you want:
- Automatic session recovery after server restarts
- Docker and Kubernetes deployment ready 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 | | Transport | URL |
|------|-------------|------------| |----------|-----|
| `get_profile` | Get email address for an account | `account` | | Streamable HTTP (preferred) | `https://hermes.squaremcp.com/mcp` |
| `search_messages` | Search INBOX by keyword / sender / subject | `q`, `maxResults`, `account` | | Legacy SSE | `https://hermes.squaremcp.com/sse` |
| `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"`).
--- ---
## 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 ```bash
# 1. Install dependencies
npm install npm install
# 2. Configure credentials
cp .env.example .env cp .env.example .env
# Edit .env with your email credentials
# 3. Run in dev mode
npm run dev npm run dev
# 4. Verify
curl http://localhost:3456/health curl http://localhost:3456/health
# → {"status":"ok","service":"hermes-mcp"}
``` ```
--- The local server runs on port `3456` by default.
## 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
```
--- ---
## Connecting to Claude.ai ## Deployment
1. Go to **Claude.ai → Settings → Connectors → Add custom connector** Production deployment notes are in:
2. Enter your server URL: `https://your-domain.com/mcp`
3. Click **Connect** 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 1. the `hermes.squaremcp.com` domain
- Building and pushing a Docker image to the local registry 2. Streamable HTTP `/mcp` as the default transport
- Applying the Kubernetes Deployment / Service / Ingress manifests 3. the dedicated client setup docs linked above
- 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

View File

@@ -7,6 +7,30 @@ const password = process.env.MYSQL_PASSWORD || '';
let pool: mysql.Pool | null = null; let pool: mysql.Pool | null = null;
async function ensureColumn(
db: mysql.PoolConnection,
tableName: string,
columnName: string,
definition: string
): Promise<void> {
const [rows] = await db.execute<mysql.RowDataPacket[]>(
`
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 { export function getPool(): mysql.Pool {
if (!pool) { if (!pool) {
throw new Error('Database pool not initialized. Call initDatabase() first.'); throw new Error('Database pool not initialized. Call initDatabase() first.');
@@ -57,12 +81,19 @@ export async function initDatabase(): Promise<void> {
code VARCHAR(255) PRIMARY KEY, code VARCHAR(255) PRIMARY KEY,
client_id VARCHAR(255), client_id VARCHAR(255),
redirect_uri TEXT, redirect_uri TEXT,
scope TEXT NULL,
code_challenge TEXT NULL,
code_challenge_method VARCHAR(20) NULL,
expires_at TIMESTAMP, expires_at TIMESTAMP,
used BOOLEAN DEFAULT FALSE, used BOOLEAN DEFAULT FALSE,
INDEX idx_expires (expires_at) 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(` await db.execute(`
CREATE TABLE IF NOT EXISTS oauth_tokens ( CREATE TABLE IF NOT EXISTS oauth_tokens (
token VARCHAR(255) PRIMARY KEY, token VARCHAR(255) PRIMARY KEY,

View File

@@ -165,6 +165,28 @@ function extractBearerToken(req: express.Request): string | undefined {
return 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) { async function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
try { try {
// No API key configured = open access // 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 redirectUri = req.query.redirect_uri as string | undefined;
const state = req.query.state as string | undefined; const state = req.query.state as string | undefined;
const scope = req.query.scope 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; const responseType = req.query.response_type as string | undefined;
if (!clientId || !redirectUri) { if (!clientId || !redirectUri) {
@@ -254,7 +278,14 @@ app.get('/oauth/authorize', async (req, res) => {
} }
res.setHeader('Content-Type', 'text/html'); 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) => { 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 redirectUri = req.body.redirect_uri as string | undefined;
const state = req.body.state as string | undefined; const state = req.body.state as string | undefined;
const scope = req.body.scope 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; const action = req.body.action as string | undefined;
if (!clientId || !redirectUri) { if (!clientId || !redirectUri) {
@@ -284,7 +317,7 @@ app.post('/oauth/authorize', async (req, res) => {
return; return;
} }
const code = await createAuthCode(clientId, redirectUri, scope); const code = await createAuthCode(clientId, redirectUri, scope, codeChallenge, codeChallengeMethod);
const url = new URL(redirectUri); const url = new URL(redirectUri);
url.searchParams.set('code', code.code); url.searchParams.set('code', code.code);
if (state) url.searchParams.set('state', state); if (state) url.searchParams.set('state', state);
@@ -299,17 +332,19 @@ app.post('/oauth/token', async (req, res) => {
return; return;
} }
const clientId = req.body.client_id as string | undefined; const basicAuth = extractBasicClientCredentials(req);
const clientSecret = req.body.client_secret as string | undefined; 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 code = req.body.code as string | undefined;
const redirectUri = req.body.redirect_uri 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' }); res.status(400).json({ error: 'invalid_request' });
return; return;
} }
const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri); const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri, codeVerifier);
if (!token) { if (!token) {
res.status(400).json({ error: 'invalid_grant' }); res.status(400).json({ error: 'invalid_grant' });
return; return;
@@ -973,7 +1008,8 @@ const oauthDiscovery = {
registration_endpoint: `${SERVER_URL}/oauth/register`, registration_endpoint: `${SERVER_URL}/oauth/register`,
response_types_supported: ['code'], response_types_supported: ['code'],
grant_types_supported: ['authorization_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 = { const protectedResourceMetadata = {

View File

@@ -18,6 +18,8 @@ interface AuthCode {
client_id: string; client_id: string;
redirect_uri: string; redirect_uri: string;
scope?: string; scope?: string;
code_challenge?: string;
code_challenge_method?: string;
expires_at: number; expires_at: number;
} }
@@ -57,6 +59,19 @@ export function generateAccessToken(): string {
return crypto.randomBytes(32).toString('hex'); 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: { export async function registerClient(body: {
client_name?: string; client_name?: string;
redirect_uris?: string[]; redirect_uris?: string[];
@@ -122,20 +137,24 @@ export async function getClient(clientId: string): Promise<Client | undefined> {
export async function createAuthCode( export async function createAuthCode(
clientId: string, clientId: string,
redirectUri: string, redirectUri: string,
scope?: string scope?: string,
codeChallenge?: string,
codeChallengeMethod?: string
): Promise<AuthCode> { ): Promise<AuthCode> {
const code: AuthCode = { const code: AuthCode = {
code: generateAuthCode(), code: generateAuthCode(),
client_id: clientId, client_id: clientId,
redirect_uri: redirectUri, redirect_uri: redirectUri,
scope, scope,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
expires_at: Date.now() + AUTH_CODE_EXPIRY_MS, expires_at: Date.now() + AUTH_CODE_EXPIRY_MS,
}; };
const pool = getPool(); const pool = getPool();
await pool.execute( await pool.execute(
'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, expires_at) VALUES (?, ?, ?, ?)', 'INSERT INTO oauth_auth_codes (code, client_id, redirect_uri, scope, code_challenge, code_challenge_method, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[code.code, clientId, redirectUri, new Date(code.expires_at)] [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}`); 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( export async function exchangeCodeForToken(
clientId: string, clientId: string,
clientSecret: string, clientSecret: string | undefined,
code: string, code: string,
redirectUri: string redirectUri: string,
codeVerifier?: string
): Promise<Token | null> { ): Promise<Token | null> {
let client: Client | undefined; let client: Client | undefined;
try { try {
@@ -156,7 +176,17 @@ export async function exchangeCodeForToken(
return null; 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'); console.log('[oauth] Invalid client credentials');
return null; return null;
} }
@@ -180,6 +210,18 @@ export async function exchangeCodeForToken(
return null; 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 // Mark auth code as used
await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]); await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]);
@@ -230,8 +272,10 @@ export function getAuthorizeHtml(params: {
redirect_uri: string; redirect_uri: string;
state?: string; state?: string;
scope?: string; scope?: string;
code_challenge?: string;
code_challenge_method?: string;
}): string { }): string {
const { client_id, redirect_uri, state, scope } = params; const { client_id, redirect_uri, state, scope, code_challenge, code_challenge_method } = params;
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -261,6 +305,8 @@ export function getAuthorizeHtml(params: {
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}"> <input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''} ${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''}
${scope ? `<input type="hidden" name="scope" value="${escapeHtml(scope)}">` : ''} ${scope ? `<input type="hidden" name="scope" value="${escapeHtml(scope)}">` : ''}
${code_challenge ? `<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge)}">` : ''}
${code_challenge_method ? `<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method)}">` : ''}
<div class="buttons"> <div class="buttons">
<button type="submit" name="action" value="deny" class="deny">Deny</button> <button type="submit" name="action" value="deny" class="deny">Deny</button>
<button type="submit" name="action" value="allow" class="allow">Allow</button> <button type="submit" name="action" value="allow" class="allow">Allow</button>