Skip to main content
Glama
BoweyLou

Obsidian MCP Server - Enhanced

by BoweyLou
layer.ts15.7 kB
import { IncomingMessage, ServerResponse } from "http"; import { URL } from "url"; import { z } from "zod"; import { config } from "../config/index.js"; import { processObsidianGlobalSearch, ObsidianGlobalSearchInputSchema, ObsidianGlobalSearchResponse } from "../mcp-server/tools/obsidianGlobalSearchTool/logic.js"; import { obsidianCreateTaskLogic, CreateTaskInputSchema, CreateTaskResponse } from "../mcp-server/tools/obsidianCreateTaskTool/logic.js"; import { obsidianTaskQueryLogic, TaskQueryInputSchema, TaskQueryResponse } from "../mcp-server/tools/obsidianTaskQueryTool/logic.js"; import { obsidianUpdateTaskLogic, UpdateTaskInputSchema, UpdateTaskResponse } from "../mcp-server/tools/obsidianUpdateTaskTool/logic.js"; import { ObsidianRestApiService } from "../services/obsidianRestAPI/index.js"; import { VaultManager } from "../services/vaultManager/index.js"; import { BaseErrorCode, McpError } from "../types-global/errors.js"; import { logger, RequestContext, requestContextService, } from "../utils/index.js"; const FetchPageParametersSchema = z.object({ filePath: z.string().min(1, "filePath is required"), format: z.enum(["markdown", "json"]).default("markdown"), }); const UpdatePageParametersSchema = z.object({ filePath: z.string().min(1, "filePath is required"), content: z.string().min(1, "content cannot be empty"), mode: z.enum(["append", "prepend", "overwrite"]).default("append"), createIfMissing: z.boolean().default(true), }); const ChatGptActionSchema = z.discriminatedUnion("action", [ z.object({ action: z.literal("searchNotes"), vault: z.string().optional(), parameters: ObsidianGlobalSearchInputSchema, }), z.object({ action: z.literal("fetchPage"), vault: z.string().optional(), parameters: FetchPageParametersSchema, }), z.object({ action: z.literal("updatePage"), vault: z.string().optional(), parameters: UpdatePageParametersSchema, }), z.object({ action: z.literal("taskQuery"), vault: z.string().optional(), parameters: TaskQueryInputSchema, }), z.object({ action: z.literal("taskCreate"), vault: z.string().optional(), parameters: CreateTaskInputSchema, }), z.object({ action: z.literal("taskUpdate"), vault: z.string().optional(), parameters: UpdateTaskInputSchema, }), ]); type ChatGptActionRequest = z.infer<typeof ChatGptActionSchema>; interface ChatGptActionResult { vaultId: string; payload: | ObsidianGlobalSearchResponse | TaskQueryResponse | CreateTaskResponse | UpdateTaskResponse | Record<string, unknown>; } interface ChatGptActionDescriptor { action: ChatGptActionRequest["action"]; summary: string; parameters: string[]; returns: string; } interface ChatGptManifest { schemaVersion: string; name: string; description: string; authentication: { type: "none" | "query-api-key"; queryParameter?: string; }; endpoints: { manifestUrl: string; actionsUrl: string; mcpUrl: string; }; actions: ChatGptActionDescriptor[]; vaults: string[]; docsUrl?: string; } const ACTION_DESCRIPTORS: ChatGptActionDescriptor[] = [ { action: "searchNotes", summary: "Full-text, regex, and date-aware vault search with pagination.", parameters: [ "`query` (string, required) – search term or regex.", "`searchInPath` (string) – limit search to a folder.", "`modified_since` / `modified_until` (string) – natural language timestamps.", "`page`/`pageSize` (number) – pagination controls.", ], returns: "Structured match list with paths, context snippets, and pagination metadata.", }, { action: "fetchPage", summary: "Fetch a markdown page as raw text or full JSON metadata.", parameters: [ "`filePath` (string, required) – vault-relative path.", "`format` ('markdown' | 'json') – defaults to markdown.", ], returns: "Markdown string or NoteJson payload for the requested page.", }, { action: "updatePage", summary: "Append, prepend, or overwrite a page using safe whole-file writes.", parameters: [ "`filePath` (string, required) – target note.", "`mode` ('append' | 'prepend' | 'overwrite') – defaults to append.", "`content` (string, required) – text to inject.", "`createIfMissing` (boolean) – defaults to true.", ], returns: "Status object describing whether the file was created or updated.", }, { action: "taskQuery", summary: "Run the Tasks-plugin aware query engine with natural language date ranges.", parameters: [ "`status`, `dateRange`, `priority`, `limit` – match Task Query tool fields.", "`folder`, `tags` – optional filters.", ], returns: "Task result set with metadata, summary counts, and formatted output text.", }, { action: "taskCreate", summary: "Create a rich Tasks-plugin compatible task inside a note, heading, or periodic note.", parameters: [ "`text` (string, required) – task body.", "`filePath`/`useActiveFile`/`usePeriodicNote` – destination.", "`insertAt`, `indentLevel`, `listStyle` – placement controls.", "Optional metadata: `dueDate`, `scheduledDate`, `priority`, `tags`, etc.", ], returns: "Created task text, file path, line number, and normalized metadata snapshot.", }, { action: "taskUpdate", summary: "Update a task line (status, metadata, relocation) using the Update Task logic.", parameters: [ "`filePath` (string) and `lineNumber` (number) or `taskId` to locate the task.", "Optional changes: `status`, `text`, `priority`, `dueDate`, `moveToHeading`, etc.", ], returns: "New task text, affected file, and execution metadata.", }, ]; interface HandleChatGptLayerOptions { req: IncomingMessage; res: ServerResponse; url: URL; parentContext: RequestContext; parseJsonBody: () => Promise<any>; ensureAuthenticated: () => boolean; vaultManager: VaultManager; mcpEndpointPath: string; } export async function handleChatGptLayerRequest( options: HandleChatGptLayerOptions, ): Promise<boolean> { if (!config.chatgptLayerEnabled) { return false; } const { url, req, res } = options; const manifestPath = config.chatgptManifestPath; const actionsPath = config.chatgptActionsPath; if (url.pathname === manifestPath) { if (req.method !== "GET") { sendJson(res, 405, { success: false, error: "Method Not Allowed", allowed: ["GET"], }); return true; } const manifest = buildManifest(options); sendJson(res, 200, { success: true, manifest, }); return true; } if (url.pathname === actionsPath) { if (req.method === "OPTIONS") { sendJson(res, 200, { success: true }); return true; } if (req.method !== "POST") { sendJson(res, 405, { success: false, error: "Method Not Allowed", allowed: ["POST"], }); return true; } if (!options.ensureAuthenticated()) { sendJson(res, 401, { success: false, error: "Unauthorized", message: "Missing or invalid MCP API key.", }); return true; } let rawBody: unknown; try { rawBody = await options.parseJsonBody(); } catch (error) { const parseContext = requestContextService.createRequestContext({ ...options.parentContext, operation: "ChatGPT_ParseJsonBody", error: error instanceof Error ? error.message : String(error), }); logger.warning("ChatGPT action JSON parsing failed", parseContext); sendJson(res, 400, { success: false, error: "Invalid JSON payload", }); return true; } const parsed = ChatGptActionSchema.safeParse(rawBody); if (!parsed.success) { sendJson(res, 400, { success: false, error: "ValidationError", details: parsed.error.flatten(), }); return true; } try { const actionResult = await executeAction(parsed.data, options); sendJson(res, 200, { success: true, action: parsed.data.action, vault: actionResult.vaultId, data: actionResult.payload, }); } catch (error) { handleActionError(error, parsed.data.action, res, options.parentContext); } return true; } return false; } function buildManifest(options: HandleChatGptLayerOptions): ChatGptManifest { const origin = `${options.url.protocol}//${options.url.host}`; return { schemaVersion: "2025-02-01", name: `${config.mcpServerName} ChatGPT MCP Bridge`, description: "HTTP manifest describing the Obsidian MCP streamable endpoint plus lightweight JSON actions for ChatGPT-managed workflows.", authentication: config.mcpAuthKey ? { type: "query-api-key", queryParameter: "api_key", } : { type: "none" }, endpoints: { manifestUrl: `${origin}${config.chatgptManifestPath}`, actionsUrl: `${origin}${config.chatgptActionsPath}`, mcpUrl: `${origin}${options.mcpEndpointPath}`, }, actions: ACTION_DESCRIPTORS, vaults: options.vaultManager.getAvailableVaults(), docsUrl: "https://github.com/BoweyLou/obsidian-mcp-server-enhanced#chatgpt-mcp-layer", }; } async function executeAction( request: ChatGptActionRequest, options: HandleChatGptLayerOptions, ): Promise<ChatGptActionResult> { const vaultId = resolveVaultId( request.vault, "vault" in request.parameters && typeof request.parameters.vault === "string" ? request.parameters.vault : undefined, options.vaultManager, ); const actionContext = requestContextService.createRequestContext({ ...options.parentContext, operation: `ChatGPT_${request.action}`, vaultId, component: "ChatGPTLayer", }); const obsidianService = options.vaultManager.getVaultService( vaultId, actionContext, ); switch (request.action) { case "searchNotes": { const cacheService = options.vaultManager.getVaultCacheService(vaultId, actionContext); const payload = await processObsidianGlobalSearch( request.parameters, actionContext, obsidianService, cacheService, ); return { vaultId, payload }; } case "fetchPage": { const { filePath, format } = request.parameters; const content = await obsidianService.getFileContent( filePath, format, actionContext, ); return { vaultId, payload: { filePath, format, content }, }; } case "updatePage": { const payload = await updatePageContent( request.parameters, obsidianService, actionContext, ); return { vaultId, payload }; } case "taskQuery": { const payload = await obsidianTaskQueryLogic( { ...request.parameters, vault: vaultId, }, actionContext, obsidianService, ); return { vaultId, payload }; } case "taskCreate": { const payload = await obsidianCreateTaskLogic( request.parameters, actionContext, obsidianService, ); return { vaultId, payload }; } case "taskUpdate": { const payload = await obsidianUpdateTaskLogic( request.parameters, actionContext, obsidianService, ); return { vaultId, payload }; } default: return assertNeverAction(request); } } function assertNeverAction(request: never): never { const action = (request as ChatGptActionRequest | undefined)?.action ?? "unknown"; throw new McpError( BaseErrorCode.UNKNOWN_ERROR, `Unsupported ChatGPT action: ${action}`, ); } function resolveVaultId( topLevelVault: string | undefined, payloadVault: string | undefined, vaultManager: VaultManager, ): string { if (topLevelVault?.trim()) { return topLevelVault.trim(); } if (payloadVault?.trim()) { return payloadVault.trim(); } return vaultManager.getDefaultVaultId(); } async function updatePageContent( params: z.infer<typeof UpdatePageParametersSchema>, obsidianService: ObsidianRestApiService, context: RequestContext, ): Promise<Record<string, unknown>> { const { filePath, content, mode, createIfMissing } = params; let created = false; if (mode === "append") { try { await obsidianService.appendFileContent(filePath, content, context); } catch (error) { if ( createIfMissing && error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND ) { await obsidianService.updateFileContent(filePath, content, context); created = true; } else { throw error; } } } else if (mode === "overwrite") { if (!createIfMissing) { await obsidianService.getFileContent(filePath, "markdown", context); } await obsidianService.updateFileContent(filePath, content, context); } else if (mode === "prepend") { let existing = ""; try { const current = await obsidianService.getFileContent( filePath, "markdown", context, ); existing = typeof current === "string" ? current : ""; } catch (error) { if ( createIfMissing && error instanceof McpError && error.code === BaseErrorCode.NOT_FOUND ) { existing = ""; created = true; } else { throw error; } } const newContent = existing ? `${content}\n${existing}` : `${content}\n`; await obsidianService.updateFileContent(filePath, newContent, context); } const message = created ? "File created and updated" : "File updated successfully"; return { filePath, mode, created, message, contentLength: content.length, }; } function handleActionError( error: unknown, action: string, res: ServerResponse, parentContext: RequestContext, ) { if (error instanceof McpError) { sendJson(res, mapErrorCodeToStatus(error.code), { success: false, action, error: { code: error.code, message: error.message, details: error.details, }, }); return; } const logContext: RequestContext = { ...parentContext, action, component: "ChatGPTLayer", }; if (!(error instanceof Error)) { logContext.rawError = error; } const errObject = error instanceof Error ? error : undefined; logger.error( "ChatGPT action failed with unexpected error", errObject, logContext, ); sendJson(res, 500, { success: false, action, error: { code: BaseErrorCode.INTERNAL_ERROR, message: error instanceof Error ? error.message : String(error), }, }); } function mapErrorCodeToStatus(code: BaseErrorCode): number { switch (code) { case BaseErrorCode.UNAUTHORIZED: return 401; case BaseErrorCode.FORBIDDEN: return 403; case BaseErrorCode.NOT_FOUND: return 404; case BaseErrorCode.CONFLICT: return 409; case BaseErrorCode.VALIDATION_ERROR: case BaseErrorCode.PARSING_ERROR: return 400; case BaseErrorCode.RATE_LIMITED: return 429; case BaseErrorCode.TIMEOUT: return 504; case BaseErrorCode.SERVICE_UNAVAILABLE: return 503; default: return 500; } } function sendJson( res: ServerResponse, status: number, body: Record<string, unknown>, ) { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body, null, 2)); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/BoweyLou/obsidian-mcp-server-enhanced'

If you have feedback or need assistance with the MCP directory API, please join our Discord server