Add multi-account OAuth, Obsidian integration, product assets, and test tooling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-04-29 09:52:53 -04:00
parent 166f5d55a6
commit e3a272c332
67 changed files with 6204 additions and 94 deletions

15
src/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY tsconfig.json ./
COPY src/ ./src/
RUN npx tsc
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3456
CMD ["node", "dist/index.js"]

272
src/clients/obsidian.ts Normal file
View File

@@ -0,0 +1,272 @@
import { readFile, writeFile, readdir, stat, mkdir } from 'fs/promises';
import { join, dirname, basename, extname } from 'path';
const VAULT_PATH = process.env['OBSIDIAN_VAULT_PATH'] ?? '/vaults';
const SYNCTHING_URL = process.env['SYNCTHING_URL'] ?? 'http://host.docker.internal:8384';
const SYNCTHING_API_KEY = process.env['SYNCTHING_API_KEY'] ?? '';
const SYNCTHING_FOLDER_ID = process.env['SYNCTHING_FOLDER_ID'] ?? 'obsidian-vault';
function parseFrontmatter(content: string): { meta: Record<string, unknown>; body: string } {
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) {
return { meta: {}, body: content };
}
const end = content.indexOf('\n---', 4);
if (end === -1) return { meta: {}, body: content };
const yaml = content.slice(4, end);
const body = content.slice(end + 4).replace(/^\r?\n/, '');
const meta: Record<string, unknown> = {};
for (const line of yaml.split('\n')) {
const colon = line.indexOf(':');
if (colon === -1) continue;
const key = line.slice(0, colon).trim();
const val = line.slice(colon + 1).trim();
if (!key) continue;
if (key === 'tags') {
// Support "tags: [a, b]" or "tags: a, b"
meta['tags'] = val
.replace(/[\[\]]/g, '')
.split(',')
.map((t) => t.trim())
.filter(Boolean);
} else {
meta[key] = val;
}
}
return { meta, body };
}
function extractTitle(filePath: string, content: string): string {
const { meta, body } = parseFrontmatter(content);
if (meta['title']) return String(meta['title']);
const h1 = body.match(/^#\s+(.+)/m);
if (h1) return h1[1].trim();
return basename(filePath, '.md');
}
async function getAllNotes(dir: string = VAULT_PATH): Promise<string[]> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const files: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await getAllNotes(full)));
} else if (entry.isFile() && extname(entry.name) === '.md') {
files.push(full);
}
}
return files;
}
function relPath(fullPath: string): string {
return fullPath.startsWith(VAULT_PATH)
? fullPath.slice(VAULT_PATH.length).replace(/^\//, '')
: fullPath;
}
export interface NoteResult {
path: string;
title: string;
excerpt: string;
tags: string[];
modified_date: string;
}
export interface FullNote {
path: string;
title: string;
content: string;
tags: string[];
links: string[];
modified_date: string;
}
export async function searchNotes(
query: string,
tags?: string[],
limit = 10,
pathFilter?: string
): Promise<NoteResult[]> {
const files = await getAllNotes();
const results: NoteResult[] = [];
const q = query.toLowerCase();
for (const file of files) {
if (results.length >= limit) break;
const rel = relPath(file);
if (pathFilter && !rel.toLowerCase().includes(pathFilter.toLowerCase())) continue;
let content: string;
try {
content = await readFile(file, 'utf-8');
} catch {
continue;
}
const { meta, body } = parseFrontmatter(content);
const fileTags = (meta['tags'] as string[]) ?? [];
const title = extractTitle(file, content);
if (tags && tags.length > 0) {
const hasAllTags = tags.every((t) => fileTags.includes(t));
if (!hasAllTags) continue;
}
const matchesQuery =
!q ||
title.toLowerCase().includes(q) ||
body.toLowerCase().includes(q) ||
rel.toLowerCase().includes(q);
if (!matchesQuery) continue;
let excerpt = body.slice(0, 200).replace(/\n+/g, ' ').trim();
if (q) {
const idx = body.toLowerCase().indexOf(q);
if (idx > 0) {
excerpt = body.slice(Math.max(0, idx - 50), idx + 150).replace(/\n+/g, ' ').trim();
}
}
if (excerpt.length >= 150) excerpt += '...';
const s = await stat(file);
results.push({
path: rel,
title,
excerpt,
tags: fileTags,
modified_date: s.mtime.toISOString(),
});
}
return results;
}
export async function getNote(notePath: string): Promise<FullNote> {
let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath);
// If path doesn't end in .md, try adding it
if (!fullPath.endsWith('.md')) fullPath += '.md';
try {
await stat(fullPath);
} catch {
// Fall back to searching by title / filename match
const files = await getAllNotes();
const needle = notePath.replace(/\.md$/i, '').toLowerCase();
const match = files.find(
(f) =>
basename(f, '.md').toLowerCase() === needle ||
relPath(f).replace(/\.md$/i, '').toLowerCase() === needle
);
if (!match) throw new Error(`Note not found: ${notePath}`);
fullPath = match;
}
const content = await readFile(fullPath, 'utf-8');
const { meta, body } = parseFrontmatter(content);
const title = extractTitle(fullPath, content);
const s = await stat(fullPath);
const links = [...body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)].map((m) => m[1].trim());
return {
path: relPath(fullPath),
title,
content,
tags: (meta['tags'] as string[]) ?? [],
links: [...new Set(links)],
modified_date: s.mtime.toISOString(),
};
}
export async function appendToNote(
notePath: string,
content: string,
createIfMissing = true,
header?: string
): Promise<{ success: boolean; path: string; bytes_written: number }> {
let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath);
if (!fullPath.endsWith('.md')) fullPath += '.md';
let existing = '';
try {
existing = await readFile(fullPath, 'utf-8');
} catch {
if (!createIfMissing) throw new Error(`Note not found: ${notePath}`);
await mkdir(dirname(fullPath), { recursive: true });
}
const separator = existing && !existing.endsWith('\n') ? '\n' : '';
const toAppend = header
? `${separator}\n## ${header}\n${content}\n`
: `${separator}${content}\n`;
await writeFile(fullPath, existing + toAppend, 'utf-8');
return {
success: true,
path: relPath(fullPath),
bytes_written: Buffer.byteLength(toAppend, 'utf-8'),
};
}
export async function updateNote(
notePath: string,
content: string,
): Promise<{ success: boolean; path: string; bytes_written: number }> {
let fullPath = notePath.startsWith('/') ? notePath : join(VAULT_PATH, notePath);
if (!fullPath.endsWith('.md')) fullPath += '.md';
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, content, 'utf-8');
return { success: true, path: relPath(fullPath), bytes_written: Buffer.byteLength(content, 'utf-8') };
}
export async function getSyncStatus(): Promise<{
status: string;
last_sync: string | null;
vault_size: number;
pending_changes: number;
}> {
if (!SYNCTHING_API_KEY) {
return { status: 'unconfigured set SYNCTHING_API_KEY', last_sync: null, vault_size: 0, pending_changes: 0 };
}
try {
const url = `${SYNCTHING_URL}/rest/db/status?folder=${SYNCTHING_FOLDER_ID}`;
const res = await fetch(url, {
headers: { 'X-API-Key': SYNCTHING_API_KEY },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`Syncthing API returned ${res.status}`);
const data = (await res.json()) as {
state: string;
stateChanged: string;
localBytes: number;
needFiles: number;
};
return {
status: data.state,
last_sync: data.stateChanged ?? null,
vault_size: data.localBytes ?? 0,
pending_changes: data.needFiles ?? 0,
};
} catch (err) {
return {
status: `error: ${(err as Error).message}`,
last_sync: null,
vault_size: 0,
pending_changes: 0,
};
}
}

81
src/db.ts Normal file
View File

@@ -0,0 +1,81 @@
import mysql from 'mysql2/promise';
const host = process.env.MYSQL_HOST || '127.0.0.1';
const port = parseInt(process.env.MYSQL_PORT || '3306', 10);
const user = process.env.MYSQL_USER || 'root';
const password = process.env.MYSQL_PASSWORD || '';
let pool: mysql.Pool | null = null;
export function getPool(): mysql.Pool {
if (!pool) {
throw new Error('Database pool not initialized. Call initDatabase() first.');
}
return pool;
}
export function isPoolReady(): boolean {
return pool !== null;
}
export async function initDatabase(): Promise<void> {
// Create database if it doesn't exist
const tmpConn = await mysql.createConnection({ host, port, user, password });
await tmpConn.execute('CREATE DATABASE IF NOT EXISTS hermes_oauth');
await tmpConn.end();
pool = mysql.createPool({
host,
port,
user,
password,
database: 'hermes_oauth',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
timezone: 'Z',
});
const db = await pool.getConnection();
try {
await db.execute(`
CREATE TABLE IF NOT EXISTS oauth_clients (
client_id VARCHAR(255) PRIMARY KEY,
client_secret VARCHAR(255) NOT NULL,
client_name VARCHAR(255),
redirect_urls JSON,
grant_types JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_used TIMESTAMP NULL,
is_static BOOLEAN DEFAULT FALSE,
INDEX idx_last_used (last_used)
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS oauth_auth_codes (
code VARCHAR(255) PRIMARY KEY,
client_id VARCHAR(255),
redirect_uri TEXT,
expires_at TIMESTAMP,
used BOOLEAN DEFAULT FALSE,
INDEX idx_expires (expires_at)
)
`);
await db.execute(`
CREATE TABLE IF NOT EXISTS oauth_tokens (
token VARCHAR(255) PRIMARY KEY,
client_id VARCHAR(255),
token_type ENUM('access', 'refresh') DEFAULT 'access',
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_expires (expires_at)
)
`);
} finally {
db.release();
}
console.log('[db] MySQL connected and schema initialized');
}

View File

@@ -1,6 +1,6 @@
import { ImapFlow } from 'imapflow';
export type Account = 'yahoo' | 'fetcherpay' | 'garfield' | 'sales' | 'leads' | 'founder';
export type Account = 'yahoo' | 'fetcherpay' | 'garfield' | 'sales' | 'leads' | 'founder' | 'gmail';
const FETCHERPAY_IMAP_HOST = process.env['FETCHERPAY_IMAP_HOST'] ?? 'mail.fetcherpay.com';
const FETCHERPAY_IMAP_PORT = parseInt(process.env['FETCHERPAY_IMAP_PORT'] ?? '30993');
@@ -42,6 +42,16 @@ function getConfig(account: Account = 'yahoo') {
process.env['FOUNDER_EMAIL'] as string,
process.env['FOUNDER_PASSWORD'] as string,
);
case 'gmail':
return {
host: 'imap.gmail.com',
port: 993,
secure: true,
auth: {
user: process.env['GMAIL_EMAIL'] as string,
pass: process.env['GMAIL_APP_PASSWORD'] as string,
},
};
default:
return {
host: 'imap.mail.yahoo.com',
@@ -73,6 +83,7 @@ export interface MessageSummary {
date: string;
seen: boolean;
size: number;
folder: string;
}
export interface FullMessage {
@@ -86,48 +97,142 @@ export interface FullMessage {
seen: boolean;
}
export async function searchMessages(query: string, maxResults = 20, account: Account = 'yahoo'): Promise<MessageSummary[]> {
return withClient(account, async (client) => {
await client.mailboxOpen('INBOX');
function parseSearchCriteria(query: string): object {
const trimmed = query.trim();
if (!trimmed) return { all: true };
const criteria = query
? { or: [{ subject: query }, { from: query }] }
: { all: true };
// Parse quoted and unquoted tokens like from:x, subject:y, to:z
const tokens: { key: string; value: string }[] = [];
const regex = /(\w+):("([^"]*)"|([^\s]+))/g;
let m: RegExpExecArray | null;
let lastIndex = 0;
while ((m = regex.exec(trimmed)) !== null) {
tokens.push({ key: m[1].toLowerCase(), value: m[3] ?? m[4] });
lastIndex = regex.lastIndex;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uids = await client.search(criteria as any, { uid: true });
const uidList: number[] = Array.isArray(uids) ? uids : [];
const recentUids = uidList.slice(-maxResults).reverse();
const remaining = trimmed.slice(lastIndex).trim();
if (remaining) {
tokens.push({ key: 'keyword', value: remaining });
}
if (recentUids.length === 0) return [];
if (tokens.length === 0) {
return { or: [{ subject: trimmed }, { from: trimmed }] };
}
const messages: MessageSummary[] = [];
for await (const msg of client.fetch(recentUids, {
envelope: true,
flags: true,
size: true,
}, { uid: true })) {
const env = msg.envelope;
messages.push({
uid: msg.uid,
messageId: env?.messageId ?? '',
subject: env?.subject ?? '(no subject)',
from: env?.from?.[0]
? `${env.from[0].name ?? ''} <${env.from[0].address ?? ''}>`.trim()
: '',
date: env?.date?.toISOString() ?? '',
seen: msg.flags?.has('\\Seen') ?? false,
size: msg.size ?? 0,
});
const parts: object[] = [];
for (const t of tokens) {
switch (t.key) {
case 'from':
parts.push({ from: t.value });
break;
case 'subject':
parts.push({ subject: t.value });
break;
case 'to':
parts.push({ to: t.value });
break;
case 'after':
case 'since': {
const d = new Date(t.value);
if (!isNaN(d.getTime())) parts.push({ since: d });
break;
}
case 'before': {
const d = new Date(t.value);
if (!isNaN(d.getTime())) parts.push({ before: d });
break;
}
case 'keyword':
default:
parts.push({ or: [{ subject: t.value }, { from: t.value }] });
break;
}
return messages;
}
if (parts.length === 1) return parts[0];
return { and: parts };
}
async function searchInFolder(
client: ImapFlow,
folder: string,
query: string,
maxResults: number,
account: Account
): Promise<MessageSummary[]> {
await client.mailboxOpen(folder);
const criteria = account === 'gmail'
? { gmailraw: query }
: parseSearchCriteria(query);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uids = await client.search(criteria as any, { uid: true });
const uidList: number[] = Array.isArray(uids) ? uids : [];
const recentUids = uidList.slice(-maxResults).reverse();
if (recentUids.length === 0) return [];
const messages: MessageSummary[] = [];
for await (const msg of client.fetch(recentUids, {
envelope: true,
flags: true,
size: true,
}, { uid: true })) {
const env = msg.envelope;
messages.push({
uid: msg.uid,
messageId: env?.messageId ?? '',
subject: env?.subject ?? '(no subject)',
from: env?.from?.[0]
? `${env.from[0].name ?? ''} <${env.from[0].address ?? ''}>`.trim()
: '',
date: env?.date?.toISOString() ?? '',
seen: msg.flags?.has('\\Seen') ?? false,
size: msg.size ?? 0,
folder,
});
}
return messages;
}
export async function searchMessages(
query: string,
maxResults = 20,
account: Account = 'yahoo',
folder?: string
): Promise<MessageSummary[]> {
return withClient(account, async (client) => {
const foldersToSearch: string[] = [];
if (folder) {
foldersToSearch.push(folder);
} else if (account === 'gmail') {
foldersToSearch.push('INBOX');
} else {
foldersToSearch.push('INBOX');
}
for (const f of foldersToSearch) {
const results = await searchInFolder(client, f, query, maxResults, account);
if (results.length > 0) return results;
}
// Fallback for Gmail: search All Mail if INBOX was empty
if (account === 'gmail' && !folder) {
const allMailResults = await searchInFolder(client, '[Gmail]/All Mail', query, maxResults, account);
if (allMailResults.length > 0) return allMailResults;
}
return [];
});
}
export async function readMessage(uid: number, account: Account = 'yahoo'): Promise<FullMessage> {
export async function readMessage(uid: number, account: Account = 'yahoo', folder = 'INBOX'): Promise<FullMessage> {
return withClient(account, async (client) => {
console.log(`[imap] readMessage uid=${uid} account=${account}`);
await client.mailboxOpen('INBOX');
console.log(`[imap] readMessage uid=${uid} account=${account} folder=${folder}`);
await client.mailboxOpen(folder);
console.log(`[imap] mailbox opened, fetching uid=${uid}`);
let result: FullMessage | null = null;
@@ -195,6 +300,7 @@ export async function getProfile(account: Account = 'yahoo'): Promise<{ email: s
sales: process.env['SALES_EMAIL'] ?? '',
leads: process.env['LEADS_EMAIL'] ?? '',
founder: process.env['FOUNDER_EMAIL'] ?? '',
gmail: process.env['GMAIL_EMAIL'] ?? '',
};
const email = emailMap[account] ?? '';
return { email, name: email.split('@')[0], account };

View File

@@ -7,24 +7,189 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import {
ListToolsRequestSchema,
CallToolRequestSchema,
ListResourcesRequestSchema,
isInitializeRequest,
} from '@modelcontextprotocol/sdk/types.js';
import { tools, handleToolCall } from './tools.js';
import { getManifest, getOpenApiSpec } from './manifest.js';
import {
registerClient,
getClient,
createAuthCode,
exchangeCodeForToken,
validateAccessToken,
getAuthorizeHtml,
} from './oauth.js';
import { initDatabase } from './db.js';
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept'],
allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept', 'x-api-key', 'Authorization'],
credentials: true
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// ── Config ─────────────────────────────────────────────────────────────────
const PORT = process.env.PORT ?? 3456;
const SERVER_URL = process.env.SERVER_URL ?? `http://localhost:${PORT}`;
const MCP_RESOURCE_URL = `${SERVER_URL}/mcp`;
const PROTECTED_RESOURCE_METADATA_URL = `${SERVER_URL}/.well-known/oauth-protected-resource`;
const SQUAREMCP_ALLOWED_ORIGINS = new Set([
'https://squaremcp.com',
'https://www.squaremcp.com',
]);
type PilotRequestBody = {
name: string;
email: string;
company: string;
role: string;
use_case: string;
timeline: string;
systems: string;
requirements: string;
submission_tag: string;
};
function getEasternDateString() {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
}
function sanitizeField(value: unknown) {
return String(value ?? '').trim();
}
function getPilotRequestBody(body: Record<string, unknown>): PilotRequestBody {
return {
name: sanitizeField(body.name),
email: sanitizeField(body.email),
company: sanitizeField(body.company),
role: sanitizeField(body.role),
use_case: sanitizeField(body.use_case),
timeline: sanitizeField(body.timeline),
systems: sanitizeField(body.systems),
requirements: sanitizeField(body.requirements),
submission_tag: sanitizeField(body.submission_tag),
};
}
function validatePilotRequest(body: PilotRequestBody) {
const requiredFields: Array<keyof PilotRequestBody> = [
'name',
'email',
'company',
'role',
'use_case',
'timeline',
'systems',
'requirements',
];
for (const field of requiredFields) {
if (!body[field]) {
return `Missing required field: ${field}`;
}
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
return 'Invalid email address';
}
return null;
}
function formatPilotRequestMarkdown(requestId: string, body: PilotRequestBody, req: express.Request) {
const submittedAt = new Date().toISOString();
const source = req.get('origin') || req.get('host') || 'unknown';
const ipAddress = req.ip || req.socket.remoteAddress || 'unknown';
return [
`### ${body.company}${body.name}`,
`- Request ID: \`${requestId}\``,
`- Submitted: ${submittedAt}`,
`- Source: ${source}`,
`- IP: ${ipAddress}`,
`- Email: ${body.email}`,
`- Role: ${body.role}`,
`- Use case: ${body.use_case}`,
`- Timeline: ${body.timeline}`,
...(body.submission_tag ? [`- Tags: ${body.submission_tag}`] : []),
'',
'**Internal systems to connect**',
body.systems,
'',
'**Security or compliance requirements**',
body.requirements,
'',
].join('\n');
}
async function appendPilotRequestToVault(requestId: string, body: PilotRequestBody, req: express.Request) {
const content = formatPilotRequestMarkdown(requestId, body, req);
const dailyNotePath = `Daily Notes/${getEasternDateString()}.md`;
await handleToolCall('obsidian_append_to_note', {
path: 'SquareMCP/Pilot Requests.md',
header: 'Pilot Requests',
content,
create_if_missing: true,
});
await handleToolCall('obsidian_append_to_note', {
path: dailyNotePath,
header: 'SquareMCP Pilot Requests',
content,
create_if_missing: true,
});
}
// ── Auth middleware ─────────────────────────────────────────────────────────
const API_KEY = process.env.MCP_API_KEY;
function extractBearerToken(req: express.Request): string | undefined {
const authHeader = req.headers.authorization as string | undefined;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.slice(7);
}
return undefined;
}
async function requireAuth(req: express.Request, res: express.Response, next: express.NextFunction) {
try {
// No API key configured = open access
if (!API_KEY) return next();
// 1. Check x-api-key header or query param (backward compatibility)
const apiKeyProvided = (req.headers['x-api-key'] as string | undefined) || (req.query.key as string | undefined);
if (apiKeyProvided === API_KEY) return next();
// 2. Check OAuth Bearer token
const bearerToken = extractBearerToken(req);
if (bearerToken && await validateAccessToken(bearerToken)) return next();
res.setHeader(
'WWW-Authenticate',
`Bearer realm="hermes", resource_metadata="${PROTECTED_RESOURCE_METADATA_URL}"`
);
res.status(401).json({ error: 'Unauthorized — provide x-api-key header, ?key= query param, or Bearer token' });
} catch (err) {
next(err);
}
}
// Request logging middleware
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${req.method} ${req.path} - ${req.headers['user-agent'] || 'no-ua'}`);
if (req.body && Object.keys(req.body).length > 0) {
if (req.body && Object.keys(req.body).length > 0 && !req.path.startsWith('/oauth')) {
console.log(` Body: ${JSON.stringify(req.body).substring(0, 500)}`);
}
next();
@@ -33,9 +198,10 @@ app.use((req, res, next) => {
function createMcpServer() {
const server = new Server(
{ name: 'hermes', version: '1.0.0' },
{ capabilities: { tools: {} } }
{ capabilities: { tools: {}, resources: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
return handleToolCall(
request.params.name,
@@ -45,49 +211,157 @@ function createMcpServer() {
return server;
}
// ── NEW: Streamable HTTP transport (MCP 1.x standard) ──────────────────────
// ── OAuth 2.0 + Dynamic Client Registration ─────────────────────────────────
// DCR: ChatGPT registers itself
app.post('/oauth/register', async (req, res) => {
const body = req.body || {};
const client = await registerClient(body);
res.status(201).json({
client_id: client.client_id,
client_secret: client.client_secret,
client_name: client.client_name,
redirect_uris: client.redirect_uris,
grant_types: ['authorization_code'],
token_endpoint_auth_method: 'client_secret_post',
});
});
// Authorization endpoint: GET shows consent form, POST handles approval
app.get('/oauth/authorize', async (req, res) => {
const clientId = req.query.client_id as string | undefined;
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 responseType = req.query.response_type as string | undefined;
if (!clientId || !redirectUri) {
res.status(400).send('Missing client_id or redirect_uri');
return;
}
if (responseType && responseType !== 'code') {
res.status(400).send('Unsupported response_type');
return;
}
const client = await getClient(clientId);
if (!client) {
res.status(400).send('Invalid client_id');
return;
}
res.setHeader('Content-Type', 'text/html');
res.send(getAuthorizeHtml({ client_id: clientId, redirect_uri: redirectUri, state, scope }));
});
app.post('/oauth/authorize', async (req, res) => {
const clientId = req.body.client_id as string | undefined;
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 action = req.body.action as string | undefined;
if (!clientId || !redirectUri) {
res.status(400).send('Missing client_id or redirect_uri');
return;
}
const client = await getClient(clientId);
if (!client) {
res.status(400).send('Invalid client_id');
return;
}
if (action !== 'allow') {
const url = new URL(redirectUri);
url.searchParams.set('error', 'access_denied');
url.searchParams.set('error_description', 'User denied authorization');
if (state) url.searchParams.set('state', state);
res.redirect(url.toString());
return;
}
const code = await createAuthCode(clientId, redirectUri, scope);
const url = new URL(redirectUri);
url.searchParams.set('code', code.code);
if (state) url.searchParams.set('state', state);
res.redirect(url.toString());
});
// Token endpoint: exchange code for access token
app.post('/oauth/token', async (req, res) => {
const grantType = req.body.grant_type as string | undefined;
if (grantType !== 'authorization_code') {
res.status(400).json({ error: 'unsupported_grant_type' });
return;
}
const clientId = req.body.client_id as string | undefined;
const clientSecret = req.body.client_secret as string | undefined;
const code = req.body.code as string | undefined;
const redirectUri = req.body.redirect_uri as string | undefined;
if (!clientId || !clientSecret || !code || !redirectUri) {
res.status(400).json({ error: 'invalid_request' });
return;
}
const token = await exchangeCodeForToken(clientId, clientSecret, code, redirectUri);
if (!token) {
res.status(400).json({ error: 'invalid_grant' });
return;
}
res.json({
access_token: token.access_token,
token_type: token.token_type,
expires_in: token.expires_in,
scope: token.scope,
});
});
// ── Streamable HTTP transport (MCP 1.x standard) ────────────────────────────
const httpTransports = new Map<string, StreamableHTTPServerTransport>();
app.post('/mcp', async (req, res) => {
async function createSession(): Promise<StreamableHTTPServerTransport> {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => {
console.log(`[mcp] Session initialized: ${id}`);
httpTransports.set(id, transport);
},
});
transport.onclose = () => {
if (transport.sessionId) {
console.log(`[mcp] Session closed: ${transport.sessionId}`);
httpTransports.delete(transport.sessionId);
}
};
const server = createMcpServer();
await server.connect(transport);
return transport;
}
app.post('/mcp', requireAuth, async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
console.log(`[mcp] POST sessionId=${sessionId ?? 'none'}, isInit=${isInitializeRequest(req.body)}`);
let transport: StreamableHTTPServerTransport;
if (sessionId && httpTransports.has(sessionId)) {
// Known active session — reuse it
console.log(`[mcp] Reusing existing session ${sessionId}`);
transport = httpTransports.get(sessionId)!;
} else if (isInitializeRequest(req.body)) {
// Initialize request: create a new session.
// Handles both first-connect (no sessionId) and re-connect after pod restart
// (stale sessionId present but not in map — we simply ignore it and issue a fresh one).
if (sessionId) {
console.warn(`[mcp] Stale session ${sessionId} re-initializing — pod may have restarted`);
}
console.log(`[mcp] Creating new session`);
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => {
console.log(`[mcp] Session initialized: ${id}`);
httpTransports.set(id, transport);
},
});
transport.onclose = () => {
if (transport.sessionId) {
console.log(`[mcp] Session closed: ${transport.sessionId}`);
httpTransports.delete(transport.sessionId);
}
};
const server = createMcpServer();
await server.connect(transport);
console.log(`[mcp] Server connected to transport`);
transport = await createSession();
} else {
// Unknown session + non-initialize request: session expired (e.g. pod restarted).
// Return 404 so MCP clients know to re-initialize rather than keep retrying.
console.warn(`[mcp] Unknown session ${sessionId ?? '(none)'}returning 404`);
res.status(404).json({ error: 'Session expired — please re-initialize' });
return;
// Stale session ID from a pod restart — transparently create a new session
// and handle the request. Our tools are stateless so no context is lost.
console.warn(`[mcp] Unknown session ${sessionId ?? '(none)'}auto-recovering with new session`);
transport = await createSession();
}
try {
@@ -99,7 +373,7 @@ app.post('/mcp', async (req, res) => {
}
});
app.get('/mcp', async (req, res) => {
app.get('/mcp', requireAuth, async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !httpTransports.has(sessionId)) {
res.status(400).json({ error: 'No active session' });
@@ -108,7 +382,7 @@ app.get('/mcp', async (req, res) => {
await httpTransports.get(sessionId)!.handleRequest(req, res);
});
app.delete('/mcp', async (req, res) => {
app.delete('/mcp', requireAuth, async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId && httpTransports.has(sessionId)) {
httpTransports.delete(sessionId);
@@ -116,10 +390,10 @@ app.delete('/mcp', async (req, res) => {
res.status(200).end();
});
// ── LEGACY: SSE transport (kept for compatibility) ──────────────────────────
// ── LEGACY: SSE transport ──────────────────────────────────────────────────
const sseTransports = new Map<string, SSEServerTransport>();
app.get('/sse', async (req, res) => {
app.get('/sse', requireAuth, async (req, res) => {
const transport = new SSEServerTransport('/messages', res);
sseTransports.set(transport.sessionId, transport);
res.on('close', () => sseTransports.delete(transport.sessionId));
@@ -127,7 +401,7 @@ app.get('/sse', async (req, res) => {
await server.connect(transport);
});
app.post('/messages', async (req, res) => {
app.post('/messages', requireAuth, async (req, res) => {
const sessionId = req.query.sessionId as string;
const transport = sseTransports.get(sessionId);
if (!transport) {
@@ -137,14 +411,244 @@ app.post('/messages', async (req, res) => {
await transport.handlePostMessage(req, res);
});
// ── Health ──────────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => {
res.json({ status: 'ok', service: 'hermes-mcp' });
// ── Tool manifest endpoint ──────────────────────────────────────────────────
app.get('/tools', requireAuth, (_req, res) => {
res.json(getManifest(SERVER_URL, !!API_KEY));
});
const PORT = process.env.PORT ?? 3456;
app.listen(PORT, () => {
console.log(`Hermes MCP server running on port ${PORT}`);
console.log(` Streamable HTTP: http://localhost:${PORT}/mcp`);
console.log(` SSE (legacy): http://localhost:${PORT}/sse`);
// ── ChatGPT MCP connector proxy ─────────────────────────────────────────────
// ChatGPT routes MCP tool calls as: POST /hermes-mcp/link_{id}/{toolName}
app.post('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) => {
const { toolName } = req.params;
const args = (req.body ?? {}) as Record<string, unknown>;
console.log(`[chatgpt-mcp] ${toolName}`, JSON.stringify(args).substring(0, 200));
try {
const result = await handleToolCall(toolName, args);
const text = result.content[0].text;
if (text.startsWith('Error:')) {
res.status(400).json({ error: text.slice(7).trim() });
return;
}
try { res.json(JSON.parse(text)); } catch { res.json({ result: text }); }
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.get('/hermes-mcp/:linkSegment/:toolName', requireAuth, async (req, res) => {
const { toolName } = req.params;
const args = req.query as Record<string, unknown>;
console.log(`[chatgpt-mcp] GET ${toolName}`, JSON.stringify(args).substring(0, 200));
try {
const result = await handleToolCall(toolName, args);
const text = result.content[0].text;
if (text.startsWith('Error:')) {
res.status(400).json({ error: text.slice(7).trim() });
return;
}
try { res.json(JSON.parse(text)); } catch { res.json({ result: text }); }
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// ── ChatGPT plugin discovery ────────────────────────────────────────────────
app.get('/.well-known/ai-plugin.json', (_req, res) => {
res.json({
schema_version: 'v1',
name_for_human: 'Hermes',
name_for_model: 'hermes',
description_for_human: 'Access your Obsidian vault notes and email accounts.',
description_for_model: 'Hermes provides read/write access to an Obsidian markdown vault (search, read, append, overwrite notes) and email operations across multiple accounts. Always use exact relative vault paths returned by search when reading or writing notes.',
auth: {
type: 'oauth',
client_url: `${SERVER_URL}/oauth/authorize`,
scope: 'obsidian email',
authorization_url: `${SERVER_URL}/oauth/token`,
authorization_content_type: 'application/x-www-form-urlencoded',
verification_tokens: {},
},
api: {
type: 'openapi',
url: `${SERVER_URL}/openapi.json`,
},
contact_email: 'garfield@fetcherpay.com',
legal_info_url: 'https://squaremcp.com/privacy',
});
});
app.get('/openapi.json', (_req, res) => {
res.json(getOpenApiSpec(SERVER_URL));
});
// ── Obsidian REST API (ChatGPT Actions) ────────────────────────────────────
function parseToolResult(result: { content: Array<{ type: string; text: string }> }): unknown {
const text = result.content[0].text;
if (text.startsWith('Error:')) throw new Error(text.slice(7).trim());
try { return JSON.parse(text); } catch { return text; }
}
app.get('/api/obsidian/search', requireAuth, async (req, res) => {
const query = req.query.query as string | undefined;
if (!query) { res.status(400).json({ error: 'query is required' }); return; }
const limit = req.query.limit ? Number(req.query.limit) : 10;
const path_filter = req.query.path_filter as string | undefined;
const tagsRaw = req.query.tags as string | undefined;
const tags = tagsRaw ? tagsRaw.split(',').map((t) => t.trim()).filter(Boolean) : undefined;
try {
const result = await handleToolCall('obsidian_search_notes', { query, limit, path_filter, tags });
res.json(parseToolResult(result));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.get('/api/obsidian/note', requireAuth, async (req, res) => {
const path = req.query.path as string | undefined;
if (!path) { res.status(400).json({ error: 'path is required' }); return; }
try {
const result = await handleToolCall('obsidian_read_note', { path });
res.json(parseToolResult(result));
} catch (err) {
const msg = (err as Error).message;
res.status(msg.toLowerCase().includes('not found') ? 404 : 500).json({ error: msg });
}
});
app.post('/api/obsidian/note/append', requireAuth, async (req, res) => {
const { path, content, header, create_if_missing } = req.body as Record<string, unknown>;
if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; }
try {
const result = await handleToolCall('obsidian_append_to_note', { path, content, header, create_if_missing });
res.json(parseToolResult(result));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.put('/api/obsidian/note', requireAuth, async (req, res) => {
const { path, content } = req.body as Record<string, unknown>;
if (!path || !content) { res.status(400).json({ error: 'path and content are required' }); return; }
try {
const result = await handleToolCall('obsidian_update_note', { path, content });
res.json(parseToolResult(result));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.get('/api/obsidian/sync', requireAuth, async (_req, res) => {
try {
const result = await handleToolCall('obsidian_sync_status', {});
res.json(parseToolResult(result));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
app.post('/api/pilot-request', async (req, res) => {
const origin = req.get('origin');
if (origin && !SQUAREMCP_ALLOWED_ORIGINS.has(origin)) {
res.status(403).json({ error: 'Origin not allowed' });
return;
}
const body = getPilotRequestBody((req.body ?? {}) as Record<string, unknown>);
const validationError = validatePilotRequest(body);
if (validationError) {
res.status(400).json({ error: validationError });
return;
}
const requestId = crypto.randomUUID();
console.log(
`[squaremcp] pilot_request requestId=${requestId} company=${body.company} email=${body.email} use_case=${body.use_case}`
);
try {
await appendPilotRequestToVault(requestId, body, req);
res.status(201).json({ ok: true, request_id: requestId });
} catch (error) {
console.error(`[squaremcp] pilot_request ERROR requestId=${requestId}:`, error);
res.status(500).json({ error: 'Failed to store pilot request' });
}
});
// ── OAuth Discovery (RFC 8414) ─────────────────────────────────────────────
const oauthDiscovery = {
issuer: SERVER_URL,
authorization_endpoint: `${SERVER_URL}/oauth/authorize`,
token_endpoint: `${SERVER_URL}/oauth/token`,
registration_endpoint: `${SERVER_URL}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
token_endpoint_auth_methods_supported: ['client_secret_post'],
};
const protectedResourceMetadata = {
resource: MCP_RESOURCE_URL,
authorization_servers: [SERVER_URL],
scopes_supported: ['email', 'obsidian'],
bearer_methods_supported: ['header'],
resource_documentation: `${SERVER_URL}/tools`,
};
app.get('/.well-known/oauth-authorization-server', (_req, res) => {
res.json(oauthDiscovery);
});
app.get('/.well-known/oauth-protected-resource', (_req, res) => {
res.json(protectedResourceMetadata);
});
app.get('/.well-known/oauth-protected-resource/mcp', (_req, res) => {
res.json(protectedResourceMetadata);
});
app.get('/.well-known/openid-configuration', (_req, res) => {
res.json({
...oauthDiscovery,
scopes_supported: ['openid', 'email', 'profile'],
claims_supported: ['sub', 'iss'],
});
});
// ── Health ──────────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => {
res.json({
status: 'ok',
service: 'hermes-mcp',
toolCount: tools.length,
transports: ['streamable-http', 'sse'],
endpoints: ['/mcp', '/sse', '/tools', '/openapi.json', '/health', '/oauth/authorize', '/oauth/token', '/oauth/register', '/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource', '/.well-known/ai-plugin.json', '/api/obsidian/search', '/api/obsidian/note', '/api/obsidian/note/append', '/api/obsidian/sync'],
});
});
async function main() {
await initDatabase();
app.listen(PORT, () => {
console.log(`Hermes MCP server running on port ${PORT}`);
console.log(` Streamable HTTP: ${SERVER_URL}/mcp`);
console.log(` SSE (legacy): ${SERVER_URL}/sse`);
console.log(` Tools manifest: ${SERVER_URL}/tools`);
console.log(` Health: ${SERVER_URL}/health`);
console.log(` OAuth authorize: ${SERVER_URL}/oauth/authorize`);
console.log(` OAuth token: ${SERVER_URL}/oauth/token`);
console.log(` OAuth register: ${SERVER_URL}/oauth/register`);
console.log(` OAuth discovery: ${SERVER_URL}/.well-known/oauth-authorization-server`);
console.log(` Resource meta: ${SERVER_URL}/.well-known/oauth-protected-resource`);
console.log(` OIDC discovery: ${SERVER_URL}/.well-known/openid-configuration`);
if (API_KEY) {
console.log(` Auth: API key + OAuth Bearer tokens accepted`);
} else {
console.warn(` Auth: NO API KEY SET — consider setting MCP_API_KEY`);
}
});
}
main().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});

554
src/manifest.ts Normal file
View File

@@ -0,0 +1,554 @@
const SCHEMA_VERSION = '1.0.0';
export function getOpenApiSpec(serverUrl: string) {
return {
openapi: '3.1.0',
info: {
title: 'Hermes',
description: 'Personal AI tools: Obsidian vault (create, read, update, search notes) and email operations across multiple accounts.',
version: '1.0.0',
},
servers: [{ url: serverUrl }],
security: [{ bearerAuth: [] }],
components: {
schemas: {},
securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer' },
},
},
paths: {
'/api/obsidian/search': {
get: {
operationId: 'obsidian_search_notes',
summary: 'Search Obsidian notes',
description: 'Full-text search across the vault by content, title, or tags. Returns matching note paths, excerpts, and metadata.',
parameters: [
{ name: 'query', in: 'query', required: true, schema: { type: 'string' }, description: 'Text to search for in note content or title' },
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 10 }, description: 'Maximum number of results to return' },
{ name: 'path_filter', in: 'query', schema: { type: 'string' }, description: 'Only return notes whose path contains this string (e.g. "Daily Notes" or "SquareMCP")' },
{ name: 'tags', in: 'query', schema: { type: 'string' }, description: 'Comma-separated list of tags all results must have' },
],
responses: {
'200': {
description: 'Matching notes',
content: {
'application/json': {
schema: {
type: 'array',
items: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative vault path — use this as-is in other calls' },
title: { type: 'string' },
excerpt: { type: 'string', description: 'Matched text excerpt (~200 chars)' },
tags: { type: 'array', items: { type: 'string' } },
modified_date: { type: 'string', format: 'date-time' },
},
},
},
},
},
},
},
},
},
'/api/obsidian/note': {
get: {
operationId: 'obsidian_read_note',
summary: 'Read an Obsidian note',
description: 'Retrieve the full markdown content of a specific note by its vault path.',
parameters: [
{ name: 'path', in: 'query', required: true, schema: { type: 'string' }, description: 'Relative vault path (e.g. "CMG Project/SoFi First Principles Readout for CMG and AIO.md")' },
],
responses: {
'200': {
description: 'Full note',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
path: { type: 'string' },
title: { type: 'string' },
content: { type: 'string', description: 'Full markdown content' },
tags: { type: 'array', items: { type: 'string' } },
links: { type: 'array', items: { type: 'string' }, description: 'Internal [[wiki-links]] found in note' },
modified_date: { type: 'string', format: 'date-time' },
},
},
},
},
},
'404': { description: 'Note not found' },
},
},
put: {
operationId: 'obsidian_update_note',
summary: 'Overwrite an Obsidian note',
description: 'Replace the entire content of a note with new markdown. Creates the note if it does not exist. Use this for rewrites or structural edits; use the append endpoint for adding content.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['path', 'content'],
properties: {
path: { type: 'string', description: 'Relative vault path' },
content: { type: 'string', description: 'Full markdown content — replaces existing content entirely' },
},
},
},
},
},
responses: {
'200': {
description: 'Note written',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
path: { type: 'string' },
bytes_written: { type: 'integer' },
},
},
},
},
},
},
},
},
'/api/obsidian/note/append': {
post: {
operationId: 'obsidian_append_to_note',
summary: 'Append to an Obsidian note',
description: 'Add markdown content to the end of a note, optionally under an H2 section header. Creates the note if it does not exist.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['path', 'content'],
properties: {
path: { type: 'string', description: 'Relative vault path (e.g. "Daily Notes/2026-04-28.md")' },
content: { type: 'string', description: 'Markdown content to append' },
header: { type: 'string', description: 'Optional H2 section header inserted before the content' },
create_if_missing: { type: 'boolean', default: true, description: 'Create the note if it does not exist' },
},
},
},
},
},
responses: {
'200': {
description: 'Content appended',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
path: { type: 'string' },
bytes_written: { type: 'integer' },
},
},
},
},
},
},
},
},
'/api/obsidian/sync': {
get: {
operationId: 'obsidian_sync_status',
summary: 'Check vault sync status',
description: 'Returns Syncthing sync state and vault statistics.',
responses: {
'200': {
description: 'Sync status',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
status: { type: 'string', description: 'Syncthing state (idle, syncing, error, etc.)' },
last_sync: { type: 'string', format: 'date-time', nullable: true },
vault_size: { type: 'integer', description: 'Total vault size in bytes' },
pending_changes: { type: 'integer', description: 'Files awaiting sync' },
},
},
},
},
},
},
},
},
},
};
}
const ACCOUNT_PARAM_SCHEMA = {
account: {
type: 'string',
enum: ['yahoo', 'fetcherpay', 'garfield', 'sales', 'leads', 'founder', 'gmail'],
description:
'Which mailbox to use: "yahoo" (gheron01@yahoo.com), "fetcherpay" (garfield.heron@fetcherpay.com), "garfield" (garfield@fetcherpay.com), "sales" (sales@fetcherpay.com), "leads" (leads@fetcherpay.com), "founder" (founder@fetcherpay.com), or "gmail" (Gmail account). Defaults to "yahoo".',
},
};
export function getManifest(serverUrl: string, authEnabled: boolean) {
return {
schema_version: SCHEMA_VERSION,
oauth_endpoints: {
issuer: serverUrl,
authorization_server_base: serverUrl,
authorization: `${serverUrl}/oauth/authorize`,
token: `${serverUrl}/oauth/token`,
registration: `${serverUrl}/oauth/register`,
},
server: {
name: 'hermes-mcp',
version: '1.0.0',
url: serverUrl,
auth: authEnabled
? {
type: 'apiKey',
header: 'x-api-key',
location: 'header',
note: 'Also accepted as ?key= query parameter',
}
: { type: 'none' },
},
tools: [
// ── Email tools ─────────────────────────────────────────────────────────
{
name: 'get_profile',
category: 'email',
description: 'Get the email account profile (email address and display name)',
when_to_use:
'User asks for their email address, display name, or account identity before sending or reading messages.',
input_schema: {
type: 'object',
properties: { ...ACCOUNT_PARAM_SCHEMA },
},
returns: {
type: 'object',
properties: {
email: { type: 'string', description: 'Full email address' },
name: { type: 'string', description: 'Username portion of the email' },
account: { type: 'string', description: 'Account alias used' },
},
},
examples: [{ account: 'yahoo' }],
},
{
name: 'search_messages',
category: 'email',
description: 'Search email messages by keyword, sender, or subject across the selected account',
when_to_use:
'User asks to find, look up, or search for emails, messages, or correspondence in Yahoo or FetcherPay mailboxes.',
input_schema: {
type: 'object',
required: ['q'],
properties: {
q: {
type: 'string',
description: 'Search query — can be a keyword, from:email, or subject:text',
},
maxResults: {
type: 'number',
description: 'Maximum number of messages to return',
default: 20,
},
...ACCOUNT_PARAM_SCHEMA,
},
},
returns: {
type: 'array',
items: {
type: 'object',
properties: {
uid: { type: 'number' },
messageId: { type: 'string' },
subject: { type: 'string' },
from: { type: 'string' },
date: { type: 'string', format: 'date-time' },
seen: { type: 'boolean' },
size: { type: 'number' },
},
},
},
examples: [{ q: 'invoice', maxResults: 10, account: 'fetcherpay' }],
},
{
name: 'read_message',
category: 'email',
description: 'Read a full email message including body text by its UID',
when_to_use:
'User asks to read, open, show, or summarize a specific email message identified by UID from search results.',
input_schema: {
type: 'object',
required: ['uid'],
properties: {
uid: {
type: 'number',
description: 'Message UID returned by search_messages',
},
...ACCOUNT_PARAM_SCHEMA,
},
},
returns: {
type: 'object',
properties: {
uid: { type: 'number' },
messageId: { type: 'string' },
subject: { type: 'string' },
from: { type: 'string' },
to: { type: 'string' },
date: { type: 'string', format: 'date-time' },
body: { type: 'string', description: 'Plain-text body (HTML stripped)' },
seen: { type: 'boolean' },
},
},
examples: [{ uid: 12345, account: 'yahoo' }],
},
{
name: 'list_folders',
category: 'email',
description: 'List all email folders and mailboxes for the selected account',
when_to_use:
'User asks what folders exist, wants to know mailbox structure, or needs folder names for email organization.',
input_schema: {
type: 'object',
properties: { ...ACCOUNT_PARAM_SCHEMA },
},
returns: {
type: 'array',
items: { type: 'string', description: 'Folder path (e.g. INBOX, Sent)' },
},
examples: [{ account: 'yahoo' }],
},
{
name: 'create_draft',
category: 'email',
description: 'Create a draft email saved to the Drafts folder',
when_to_use:
'User wants to compose or save a draft email without sending it immediately.',
input_schema: {
type: 'object',
required: ['to', 'subject', 'body'],
properties: {
to: { type: 'string', description: 'Recipient email address' },
subject: { type: 'string', description: 'Email subject line' },
body: { type: 'string', description: 'Email body in plain text' },
...ACCOUNT_PARAM_SCHEMA,
},
},
returns: {
type: 'string',
description: 'Confirmation message indicating the draft was created',
},
examples: [
{
to: 'partner@example.com',
subject: 'Meeting follow-up',
body: 'Thanks for the call today.',
account: 'fetcherpay',
},
],
},
{
name: 'send_email',
category: 'email',
description: 'Send an email immediately via SMTP',
when_to_use:
'User asks to send an email, message, or mail to someone right now.',
input_schema: {
type: 'object',
required: ['to', 'subject', 'body'],
properties: {
to: { type: 'string', description: 'Recipient email address' },
subject: { type: 'string', description: 'Email subject line' },
body: { type: 'string', description: 'Email body in plain text' },
...ACCOUNT_PARAM_SCHEMA,
},
},
returns: {
type: 'string',
description: 'SMTP message ID confirming the send',
},
examples: [
{
to: 'team@fetcherpay.com',
subject: 'Weekly update',
body: 'Here is the weekly summary.',
account: 'garfield',
},
],
},
// ── Obsidian tools ──────────────────────────────────────────────────────
{
name: 'obsidian_search_notes',
category: 'obsidian',
description: 'Full-text search across the Obsidian vault by content, tags, or title',
when_to_use:
'User mentions "my notes", "in obsidian", "I wrote about", asks about personal knowledge, or references past notes.',
input_schema: {
type: 'object',
required: ['query'],
properties: {
query: {
type: 'string',
description: 'Text to search for in note content or title',
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Filter by Obsidian tags (all must match)',
},
limit: {
type: 'number',
description: 'Max results to return',
default: 10,
},
path_filter: {
type: 'string',
description: 'Only return notes whose path contains this string (e.g. "Daily Notes")',
},
},
},
returns: {
type: 'array',
items: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative file path in vault' },
title: { type: 'string' },
excerpt: { type: 'string', description: 'Matched text excerpt (~200 chars)' },
tags: { type: 'array', items: { type: 'string' } },
modified_date: { type: 'string', format: 'date-time' },
},
},
},
examples: [{ query: 'funding strategy', limit: 5 }],
},
{
name: 'obsidian_read_note',
category: 'obsidian',
description: 'Retrieve the complete content of a specific Obsidian note by path or title',
when_to_use:
'User asks to read, open, show, or see a specific note by path or title.',
input_schema: {
type: 'object',
required: ['path'],
properties: {
path: {
type: 'string',
description: 'File path relative to vault root (e.g. "Daily Notes/2026-04-15.md") or just the note title/filename',
},
},
},
returns: {
type: 'object',
properties: {
path: { type: 'string' },
title: { type: 'string' },
content: { type: 'string', description: 'Full markdown content' },
tags: { type: 'array', items: { type: 'string' } },
links: {
type: 'array',
items: { type: 'string' },
description: 'Internal wiki-style links extracted from content',
},
modified_date: { type: 'string', format: 'date-time' },
},
},
examples: [{ path: 'Projects/FetcherPay.md' }],
},
{
name: 'obsidian_append_to_note',
category: 'obsidian',
description: 'Append markdown content to any Obsidian note, creating it if missing',
when_to_use:
'User wants to log, save, journal, or add content to daily notes or specific vault files.',
input_schema: {
type: 'object',
required: ['path', 'content'],
properties: {
path: {
type: 'string',
description: 'File path relative to vault root (e.g. "Daily Notes/2026-04-15.md")',
},
content: {
type: 'string',
description: 'Markdown content to append',
},
create_if_missing: {
type: 'boolean',
description: 'Create the note if it does not exist',
default: true,
},
header: {
type: 'string',
description: 'Optional H2 section header to insert before the content',
},
},
},
returns: {
type: 'object',
properties: {
success: { type: 'boolean' },
path: { type: 'string' },
bytes_written: { type: 'number' },
},
},
examples: [
{
path: 'Daily Notes/2026-04-15.md',
content: '- Met with investor at 2pm',
header: 'Meetings',
},
],
},
{
name: 'obsidian_sync_status',
category: 'obsidian',
description: 'Check Syncthing sync status and vault statistics',
when_to_use:
'User asks if their vault is synced, up to date, or about device connectivity for Obsidian notes.',
input_schema: {
type: 'object',
properties: {},
},
returns: {
type: 'object',
properties: {
status: { type: 'string', description: 'Syncthing state (e.g. idle, syncing, error)' },
last_sync: {
type: ['string', 'null'],
format: 'date-time',
description: 'ISO timestamp of last state change',
},
vault_size: { type: 'number', description: 'Total bytes in vault' },
pending_changes: { type: 'number', description: 'Files needing sync' },
},
},
examples: [{}],
},
],
categories: {
obsidian: {
description: 'Personal knowledge management via Obsidian vault',
icon: '📝',
},
email: {
description: 'Email operations for Yahoo, FetcherPay, and Gmail accounts',
icon: '📧',
},
},
};
}

281
src/oauth.ts Normal file
View File

@@ -0,0 +1,281 @@
import crypto from 'crypto';
import type { RowDataPacket } from 'mysql2/promise';
import { getPool, isPoolReady } from './db.js';
const AUTH_CODE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
export interface Client {
client_id: string;
client_secret: string;
client_name: string;
redirect_uris: string[];
created_at: number;
}
interface AuthCode {
code: string;
client_id: string;
redirect_uri: string;
scope?: string;
expires_at: number;
}
interface Token {
access_token: string;
token_type: string;
expires_in: number;
scope?: string;
expires_at: number;
}
// Cleanup expired rows every 60 seconds
setInterval(async () => {
if (!isPoolReady()) return;
try {
const pool = getPool();
await pool.execute('DELETE FROM oauth_auth_codes WHERE expires_at < NOW() OR used = TRUE');
await pool.execute('DELETE FROM oauth_tokens WHERE expires_at < NOW()');
} catch (err) {
console.error('[oauth] Cleanup error:', err);
}
}, 60_000);
export function generateClientId(): string {
return crypto.randomUUID();
}
export function generateClientSecret(): string {
return crypto.randomBytes(32).toString('hex');
}
export function generateAuthCode(): string {
return crypto.randomBytes(32).toString('hex');
}
export function generateAccessToken(): string {
return crypto.randomBytes(32).toString('hex');
}
export async function registerClient(body: {
client_name?: string;
redirect_uris?: string[];
[key: string]: unknown;
}): Promise<Client> {
const client: Client = {
client_id: generateClientId(),
client_secret: generateClientSecret(),
client_name: body.client_name || 'Unnamed Client',
redirect_uris: Array.isArray(body.redirect_uris) ? body.redirect_uris : [],
created_at: Date.now(),
};
const pool = getPool();
await pool.execute(
'INSERT INTO oauth_clients (client_id, client_secret, client_name, redirect_urls, grant_types) VALUES (?, ?, ?, ?, ?)',
[
client.client_id,
client.client_secret,
client.client_name,
JSON.stringify(client.redirect_uris),
JSON.stringify(['authorization_code']),
]
);
console.log(`[oauth] Registered client ${client.client_id} (${client.client_name})`);
return client;
}
export async function getClient(clientId: string): Promise<Client | undefined> {
try {
const pool = getPool();
const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT * FROM oauth_clients WHERE client_id = ?',
[clientId]
);
if (!Array.isArray(rows) || rows.length === 0) {
return undefined;
}
const row = rows[0];
// Update last_used
await pool.execute(
'UPDATE oauth_clients SET last_used = CURRENT_TIMESTAMP WHERE client_id = ?',
[clientId]
);
return {
client_id: row.client_id,
client_secret: row.client_secret,
client_name: row.client_name,
redirect_uris: Array.isArray(row.redirect_urls) ? row.redirect_urls : JSON.parse(row.redirect_urls || '[]'),
created_at: new Date(row.created_at).getTime(),
};
} catch (err) {
console.error('[oauth] getClient error:', err);
return undefined;
}
}
export async function createAuthCode(
clientId: string,
redirectUri: string,
scope?: string
): Promise<AuthCode> {
const code: AuthCode = {
code: generateAuthCode(),
client_id: clientId,
redirect_uri: redirectUri,
scope,
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)]
);
console.log(`[oauth] Created auth code ${code.code.slice(0, 8)}... for client ${clientId}`);
return code;
}
export async function exchangeCodeForToken(
clientId: string,
clientSecret: string,
code: string,
redirectUri: string
): Promise<Token | null> {
let client: Client | undefined;
try {
client = await getClient(clientId);
} catch (err) {
console.error('[oauth] getClient error during token exchange:', err);
return null;
}
if (!client || client.client_secret !== clientSecret) {
console.log('[oauth] Invalid client credentials');
return null;
}
const pool = getPool();
const db = await pool.getConnection();
try {
const [rows] = await db.execute<RowDataPacket[]>(
'SELECT * FROM oauth_auth_codes WHERE code = ? AND used = FALSE AND expires_at > NOW()',
[code]
);
if (!Array.isArray(rows) || rows.length === 0) {
console.log('[oauth] Auth code not found or expired');
return null;
}
const authCode = rows[0];
if (authCode.client_id !== clientId || authCode.redirect_uri !== redirectUri) {
console.log('[oauth] Auth code client/redirect mismatch');
return null;
}
// Mark auth code as used
await db.execute('UPDATE oauth_auth_codes SET used = TRUE WHERE code = ?', [code]);
const token: Token = {
access_token: generateAccessToken(),
token_type: 'Bearer',
expires_in: TOKEN_EXPIRY_MS / 1000,
scope: authCode.scope || undefined,
expires_at: Date.now() + TOKEN_EXPIRY_MS,
};
await db.execute(
'INSERT INTO oauth_tokens (token, client_id, token_type, expires_at) VALUES (?, ?, ?, ?)',
[token.access_token, clientId, 'access', new Date(token.expires_at)]
);
console.log(`[oauth] Issued token ${token.access_token.slice(0, 8)}... for client ${clientId}`);
return token;
} catch (err) {
console.error('[oauth] exchangeCodeForToken error:', err);
return null;
} finally {
db.release();
}
}
export async function validateAccessToken(tokenValue: string): Promise<boolean> {
try {
const pool = getPool();
const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT * FROM oauth_tokens WHERE token = ? AND expires_at > NOW()',
[tokenValue]
);
if (!Array.isArray(rows) || rows.length === 0) {
return false;
}
return true;
} catch (err) {
console.error('[oauth] validateAccessToken error:', err);
return false;
}
}
export function getAuthorizeHtml(params: {
client_id: string;
redirect_uri: string;
state?: string;
scope?: string;
}): string {
const { client_id, redirect_uri, state, scope } = params;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authorize Hermes MCP</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
.card { background: white; padding: 2rem; border-radius: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); max-width: 400px; width: 100%; }
h1 { margin: 0 0 1rem; font-size: 1.5rem; color: #111; }
p { color: #555; line-height: 1.5; margin: 0.5rem 0; }
.client { font-weight: 600; color: #111; }
.scopes { background: #f9f9f9; padding: 0.75rem; border-radius: 8px; margin: 1rem 0; font-size: 0.9rem; color: #333; }
.buttons { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
button { flex: 1; padding: 0.75rem; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; }
.allow { background: #111; color: white; }
.deny { background: #e5e5e5; color: #333; }
</style>
</head>
<body>
<div class="card">
<h1>Authorize Hermes MCP</h1>
<p>Client <span class="client">${escapeHtml(client_id)}</span> wants to access your Hermes tools (email + Obsidian vault).</p>
<div class="scopes">Scopes: ${escapeHtml(scope || 'default')}</div>
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}">
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}">
${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''}
${scope ? `<input type="hidden" name="scope" value="${escapeHtml(scope)}">` : ''}
<div class="buttons">
<button type="submit" name="action" value="deny" class="deny">Deny</button>
<button type="submit" name="action" value="allow" class="allow">Allow</button>
</div>
</form>
</div>
</body>
</html>`;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

View File

@@ -26,6 +26,16 @@ function getSmtpTransport(account: Account = 'yahoo') {
return fetcherpaySmtpTransport(process.env['LEADS_EMAIL']!, process.env['LEADS_PASSWORD']!);
case 'founder':
return fetcherpaySmtpTransport(process.env['FOUNDER_EMAIL']!, process.env['FOUNDER_PASSWORD']!);
case 'gmail':
return nodemailer.createTransport({
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env['GMAIL_EMAIL']!,
pass: process.env['GMAIL_APP_PASSWORD']!,
},
});
default:
return nodemailer.createTransport({
host: 'smtp.mail.yahoo.com',
@@ -47,6 +57,7 @@ function getSenderEmail(account: Account = 'yahoo'): string {
sales: process.env['SALES_EMAIL'] ?? '',
leads: process.env['LEADS_EMAIL'] ?? '',
founder: process.env['FOUNDER_EMAIL'] ?? '',
gmail: process.env['GMAIL_EMAIL'] ?? '',
};
return emailMap[account] ?? '';
}
@@ -88,17 +99,30 @@ export async function createDraft(
leads: { user: process.env['LEADS_EMAIL']!, pass: process.env['LEADS_PASSWORD']! },
founder: { user: process.env['FOUNDER_EMAIL']!, pass: process.env['FOUNDER_PASSWORD']! },
};
const imapConfig = fetcherpayImapAccounts[account]
? { ...fetcherpayImapBase, auth: fetcherpayImapAccounts[account]! }
: {
host: 'imap.mail.yahoo.com',
port: 993,
secure: true,
auth: {
user: process.env['YAHOO_EMAIL']!,
pass: process.env['YAHOO_APP_PASSWORD']!,
},
};
let imapConfig;
if (fetcherpayImapAccounts[account]) {
imapConfig = { ...fetcherpayImapBase, auth: fetcherpayImapAccounts[account]! };
} else if (account === 'gmail') {
imapConfig = {
host: 'imap.gmail.com',
port: 993,
secure: true,
auth: {
user: process.env['GMAIL_EMAIL']!,
pass: process.env['GMAIL_APP_PASSWORD']!,
},
};
} else {
imapConfig = {
host: 'imap.mail.yahoo.com',
port: 993,
secure: true,
auth: {
user: process.env['YAHOO_EMAIL']!,
pass: process.env['YAHOO_APP_PASSWORD']!,
},
};
}
const client = new ImapFlow(imapConfig);
await client.connect();

View File

@@ -1,12 +1,13 @@
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { searchMessages, readMessage, getProfile, listFolders, type Account } from './imap.js';
import { sendEmail, createDraft } from './smtp.js';
import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js';
const ACCOUNT_PARAM = {
account: {
type: 'string',
enum: ['yahoo', 'fetcherpay', 'garfield', 'sales', 'leads', 'founder'],
description: 'Which mailbox to use: "yahoo" (gheron01@yahoo.com), "fetcherpay" (garfield.heron@fetcherpay.com), "garfield" (garfield@fetcherpay.com), "sales" (sales@fetcherpay.com), "leads" (leads@fetcherpay.com), or "founder" (founder@fetcherpay.com). Defaults to "yahoo".',
enum: ['yahoo', 'fetcherpay', 'garfield', 'sales', 'leads', 'founder', 'gmail'],
description: 'Which mailbox to use: "yahoo" (gheron01@yahoo.com), "fetcherpay" (garfield.heron@fetcherpay.com), "garfield" (garfield@fetcherpay.com), "sales" (sales@fetcherpay.com), "leads" (leads@fetcherpay.com), "founder" (founder@fetcherpay.com), or "gmail" (Gmail account). Defaults to "yahoo".',
},
};
@@ -27,6 +28,7 @@ export const tools: Tool[] = [
properties: {
q: { type: 'string', description: 'Search query (keyword, from:email, subject:text)' },
maxResults: { type: 'number', description: 'Max messages to return (default 20)' },
folder: { type: 'string', description: 'Folder to search (default INBOX). For Gmail, [Gmail]/All Mail searches everywhere.' },
...ACCOUNT_PARAM,
},
required: ['q'],
@@ -39,6 +41,7 @@ export const tools: Tool[] = [
type: 'object',
properties: {
uid: { type: 'number', description: 'Message UID from search results' },
folder: { type: 'string', description: 'Folder the message was found in (default INBOX). Use the folder value from search_results.' },
...ACCOUNT_PARAM,
},
required: ['uid'],
@@ -80,6 +83,97 @@ export const tools: Tool[] = [
required: ['to', 'subject', 'body'],
},
},
// ── Obsidian tools ────────────────────────────────────────────────────────
{
name: 'obsidian_search_notes',
description:
'Search across the Obsidian vault by content, tags, or title. Use when the user references "my notes", "in obsidian", "I wrote about", or needs personal knowledge retrieval. Returns note paths relative to the vault root (e.g. "Daily Notes/2026-04-15.md"). Always use these exact relative paths when calling obsidian_read_note or obsidian_append_to_note.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Text to search for in note content or title' },
tags: {
type: 'array',
items: { type: 'string' },
description: 'Filter by Obsidian tags (all must match)',
},
limit: { type: 'number', description: 'Max results to return (default 10)' },
path_filter: {
type: 'string',
description: 'Only return notes whose relative vault path contains this string (e.g. "Daily Notes")',
},
},
required: ['query'],
},
},
{
name: 'obsidian_read_note',
description:
'Retrieve the full content of a specific Obsidian note by relative vault path. Use when the user asks to read, open, or show a specific note. The path must be relative to the vault root (e.g. "Daily Notes/2026-04-15.md"). Do not use absolute filesystem paths.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'File path relative to the vault root (e.g. "Daily Notes/2026-04-15.md") or just the note title/filename',
},
},
required: ['path'],
},
},
{
name: 'obsidian_append_to_note',
description:
'Append markdown content to an Obsidian daily note or any specific vault note. Use for logging, journaling, capturing insights from email, or recording action items in your notes. Creates the note if it does not exist. The path must be relative to the vault root (e.g. "Daily Notes/2026-04-15.md"). Do not use absolute filesystem paths.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'File path relative to the vault root (e.g. "Daily Notes/2026-04-15.md")',
},
content: { type: 'string', description: 'Markdown content to append' },
create_if_missing: {
type: 'boolean',
description: 'Create the note if it does not exist (default true)',
},
header: {
type: 'string',
description: 'Optional H2 section header to add before the content',
},
},
required: ['path', 'content'],
},
},
{
name: 'obsidian_update_note',
description:
'Overwrite the entire content of an Obsidian note with new markdown. Use when you need to replace, rewrite, or structurally edit an existing note rather than append to it. Creates the note if it does not exist.',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'File path relative to vault root (e.g. "Daily Notes/2026-04-15.md")',
},
content: {
type: 'string',
description: 'Full markdown content to write — replaces existing content entirely',
},
},
required: ['path', 'content'],
},
},
{
name: 'obsidian_sync_status',
description:
'Check the sync status of the Obsidian vault via Syncthing. Use when the user asks if their notes are synced or up to date.',
inputSchema: {
type: 'object',
properties: {},
},
},
];
function acct(args: Record<string, unknown>): Account {
@@ -101,11 +195,11 @@ export async function handleToolCall(
break;
case 'search_messages':
result = await searchMessages(args.q as string, (args.maxResults as number) ?? 20, acct(args));
result = await searchMessages(args.q as string, (args.maxResults as number) ?? 20, acct(args), args.folder as string | undefined);
break;
case 'read_message':
result = await readMessage(args.uid as number, acct(args));
result = await readMessage(args.uid as number, acct(args), args.folder as string | undefined);
break;
case 'list_folders':
@@ -120,6 +214,37 @@ export async function handleToolCall(
result = await sendEmail(args.to as string, args.subject as string, args.body as string, acct(args));
break;
// ── Obsidian ──────────────────────────────────────────────────────────
case 'obsidian_search_notes':
result = await searchNotes(
args.query as string,
args.tags as string[] | undefined,
(args.limit as number) ?? 10,
args.path_filter as string | undefined
);
break;
case 'obsidian_read_note':
result = await getNote(args.path as string);
break;
case 'obsidian_append_to_note':
result = await appendToNote(
args.path as string,
args.content as string,
(args.create_if_missing as boolean) ?? true,
args.header as string | undefined
);
break;
case 'obsidian_update_note':
result = await updateNote(args.path as string, args.content as string);
break;
case 'obsidian_sync_status':
result = await getSyncStatus();
break;
// Legacy Yahoo-prefixed names — keep working for any cached Claude sessions
case 'yahoo_get_profile':
result = await getProfile('yahoo');