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

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);
});