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:
554
src/manifest.ts
Normal file
554
src/manifest.ts
Normal 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: '📧',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user