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

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: '📧',
},
},
};
}