diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index 70537f7fc..f71aab51d 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -10,7 +10,8 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts --version 0.1.0", - "check-types": "tsc --noEmit --skipLibCheck" + "check-types": "tsc --noEmit --skipLibCheck", + "test:mcp-bridge": "tsx --test tests/mcpBridge.test.ts tests/mcpBridgeWrite.test.ts tests/mcpBridgeWriteExecutor.test.ts" }, "keywords": [], "author": "", diff --git a/apps/obsidian/src/index.ts b/apps/obsidian/src/index.ts index bd707c289..0a8920129 100644 --- a/apps/obsidian/src/index.ts +++ b/apps/obsidian/src/index.ts @@ -6,6 +6,7 @@ import { MarkdownView, WorkspaceLeaf, Notice, + Platform, } from "obsidian"; import { EditorView } from "@codemirror/view"; import { SettingsTab } from "~/components/Settings"; @@ -39,11 +40,16 @@ import { } from "~/utils/relationsStore"; import { migrateImportFolderMetadata } from "./utils/importFolderMetadata"; import { registerTemplateSettingsSync } from "~/utils/templateSettingsSync"; +import { + createMcpBridgeService, + type McpBridgeService, +} from "~/services/mcpBridge"; export default class DiscourseGraphPlugin extends Plugin { settings: Settings = { ...DEFAULT_SETTINGS }; private tagNodeHandler: TagNodeHandler | null = null; private fileChangeListener: FileChangeListener | null = null; + private mcpBridge: McpBridgeService | null = null; private currentViewActions: { leaf: WorkspaceLeaf; action: HTMLElement }[] = []; private pendingCanvasSwitches = new Set(); @@ -86,6 +92,13 @@ export default class DiscourseGraphPlugin extends Plugin { registerCommands(this); this.addSettingTab(new SettingsTab(this.app, this)); + if (Platform.isDesktop) { + this.mcpBridge = createMcpBridgeService(this); + void this.mcpBridge.start().catch((error: unknown) => { + console.error("Failed to start MCP bridge:", error); + }); + } + this.registerEvent( this.app.workspace.on( "active-leaf-change", @@ -442,5 +455,16 @@ export default class DiscourseGraphPlugin extends Plugin { this.fileChangeListener.cleanup(); this.fileChangeListener = null; } + + if (this.mcpBridge) { + void this.mcpBridge.stop().catch((error: unknown) => { + console.error("Failed to stop MCP bridge:", error); + }); + this.mcpBridge = null; + } + + if (typeof window !== "undefined") { + delete window.dgMcpBridge; + } } } diff --git a/apps/obsidian/src/services/mcpBridge.ts b/apps/obsidian/src/services/mcpBridge.ts new file mode 100644 index 000000000..cc0ffa97e --- /dev/null +++ b/apps/obsidian/src/services/mcpBridge.ts @@ -0,0 +1,309 @@ +import { Notice, Platform } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import { handleMcpBridgeHttpRequest } from "./mcpBridgeHttp.js"; +import { createMcpBridgeReadApi } from "./mcpBridgeRead.js"; +import { createMcpBridgeWriteApi } from "./mcpBridgeWrite.js"; +import { createMcpBridgeWriteStore } from "./mcpBridgeWriteStore.js"; +import { + createMcpWriteApprovalService, + type McpWriteApprovalService, +} from "./mcpWriteApprovalService.js"; +import { + buildMcpBridgeHealth, + DEFAULT_MCP_BRIDGE_PORT, + type McpBridgeContext, + type McpBridgeHealth, +} from "./mcpBridge.types.js"; +import { + getLocalSpaceUri, + getSupabaseContext, + getVaultId, +} from "~/utils/supabaseContext"; + +export { + DEFAULT_MCP_BRIDGE_PORT, + MCP_BRIDGE_CONTEXT_PATH, + MCP_BRIDGE_HEALTH_PATH, + MCP_BRIDGE_SERVICE_NAME, + MCP_BRIDGE_VERSION, + buildMcpBridgeHealth, +} from "./mcpBridge.types.js"; +export type { McpBridgeContext, McpBridgeHealth } from "./mcpBridge.types.js"; +export type { McpBridgeRequestHandler } from "./mcpBridgeHttp.js"; +export { handleMcpBridgeHttpRequest } from "./mcpBridgeHttp.js"; + +export type McpBridgeService = { + getPort: () => number | null; + getHealth: () => McpBridgeHealth | null; + start: () => Promise<{ port: number } | null>; + stop: () => Promise; + getApprovalState: () => ReturnType | null; +}; + +type HttpServer = { + close: (callback?: (error?: Error) => void) => void; + listen: ( + port: number, + host: string, + callback: () => void, + ) => void; + on: (event: string, callback: (error: NodeJS.ErrnoException) => void) => void; + address: () => { port: number } | string | null; +}; + +type HttpModule = { + createServer: ( + handler: ( + request: import("node:http").IncomingMessage, + response: import("node:http").ServerResponse, + ) => void, + ) => HttpServer; +}; + +/** Dynamic require โ€” static `node:http` imports break Obsidian's Electron bundle. */ +const loadHttpModule = (): HttpModule | null => { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require("http") as HttpModule; + } catch (error) { + console.error("[dg-mcp-bridge] Node http module unavailable:", error); + return null; + } +}; + +const bridgeBaseUrl = (port: number): string => `http://127.0.0.1:${port}`; + +const writeJson = ( + response: import("node:http").ServerResponse, + statusCode: number, + payload: unknown, +): void => { + response.writeHead(statusCode, { + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET,OPTIONS", + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/json", + }); + response.end(JSON.stringify(payload)); +}; + +export const buildMcpBridgeContext = async ( + plugin: DiscourseGraphPlugin, +): Promise => { + const vaultId = getVaultId(plugin.app); + const vaultName = plugin.app.vault.getName() || "obsidian-vault"; + const syncEnabled = plugin.settings.syncModeEnabled === true; + const spaceUrl = getLocalSpaceUri(plugin.app); + + const base: McpBridgeContext = { + platform: "Obsidian", + vaultId, + vaultName, + syncEnabled, + spaceUrl: syncEnabled ? spaceUrl : undefined, + }; + + if (!syncEnabled) { + return base; + } + + try { + const context = await getSupabaseContext(plugin); + if (!context) { + return base; + } + + return { + ...base, + spaceId: context.spaceId, + spaceUrl, + spacePassword: context.spacePassword, + }; + } catch (error) { + console.error("[dg-mcp-bridge] Failed to load Supabase context:", error); + return base; + } +}; + +export const createMcpBridgeService = ( + plugin: DiscourseGraphPlugin, +): McpBridgeService => { + let server: HttpServer | undefined; + let port: number | null = null; + let health: McpBridgeHealth | null = null; + const writeStore = createMcpBridgeWriteStore(); + const writeApi = createMcpBridgeWriteApi(writeStore); + let approvalService: McpWriteApprovalService | null = null; + + const getHandler = () => ({ + getHealth: () => { + if (!health || port === null) { + throw new Error("MCP bridge is not running"); + } + return health; + }, + getContext: () => buildMcpBridgeContext(plugin), + read: createMcpBridgeReadApi(plugin), + write: writeApi, + }); + + const stop = async (): Promise => { + approvalService?.stop(); + approvalService = null; + + if (!server) { + port = null; + health = null; + return; + } + + const activeServer = server; + server = undefined; + port = null; + health = null; + + await new Promise((resolve, reject) => { + activeServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }; + + const start = async (): Promise<{ port: number } | null> => { + if (!Platform.isDesktop) { + console.warn("[dg-mcp-bridge] MCP bridge is only available on desktop"); + return null; + } + + const http = loadHttpModule(); + if (!http) { + new Notice( + "Discourse Graph MCP bridge: Node http module unavailable on this platform.", + 8000, + ); + return null; + } + + if (server && port !== null) { + return { port }; + } + + const vaultId = getVaultId(plugin.app); + const vaultName = plugin.app.vault.getName() || "obsidian-vault"; + const listenPort = DEFAULT_MCP_BRIDGE_PORT; + + return new Promise((resolve) => { + const bridgeServer = http.createServer((request, response) => { + handleMcpBridgeHttpRequest(request, response, getHandler()).catch( + (error: unknown) => { + console.error("[dg-mcp-bridge] Request error:", error); + if (!response.headersSent) { + writeJson(response, 500, { + ok: false, + error: + error instanceof Error ? error.message : String(error), + }); + } else { + response.end(); + } + }, + ); + }); + + bridgeServer.on("error", (error: NodeJS.ErrnoException) => { + const message = `Could not start MCP bridge on port ${listenPort}: ${error.message}`; + console.error(`[dg-mcp-bridge] ${message}`); + new Notice(`Discourse Graph MCP bridge: ${error.message}`, 8000); + resolve(null); + }); + + bridgeServer.listen(listenPort, "127.0.0.1", () => { + const address = bridgeServer.address(); + const boundPort = + typeof address === "object" && address?.port + ? address.port + : listenPort; + + server = bridgeServer; + port = boundPort; + health = buildMcpBridgeHealth({ + port: boundPort, + vaultId, + vaultName, + }); + + console.error( + `[dg-mcp-bridge] Listening at ${bridgeBaseUrl(boundPort)}`, + ); + new Notice( + `Discourse Graph MCP bridge listening on port ${boundPort}`, + 5000, + ); + + if (typeof window !== "undefined") { + approvalService = createMcpWriteApprovalService({ + plugin, + writeApi, + }); + approvalService.start(); + + window.dgMcpBridge = { + getHealth: () => health as McpBridgeHealth, + getPort: () => port, + getPendingWriteCount: () => writeApi.pendingCount(), + listPendingWrites: () => writeApi.listPendingWrites(), + getState: () => ({ + health: health as McpBridgeHealth, + port, + ...approvalService!.getState(), + }), + refresh: () => approvalService?.refresh(), + approveBatch: (batchId: string) => + approvalService?.approveBatch(batchId) ?? Promise.resolve(), + rejectBatch: (batchId: string) => + approvalService?.rejectBatch(batchId) ?? Promise.resolve(), + }; + } + + resolve({ port: boundPort }); + }); + }); + }; + + return { + getPort: () => port, + getHealth: () => health, + start, + stop, + getApprovalState: () => approvalService?.getState() ?? null, + }; +}; + +declare global { + interface Window { + dgMcpBridge?: { + getHealth: () => McpBridgeHealth; + getPort: () => number | null; + getPendingWriteCount: () => number; + listPendingWrites: () => ReturnType< + ReturnType["listPendingWrites"] + >; + getState: () => { + health: McpBridgeHealth; + port: number | null; + pendingCount: number; + pendingWrites: ReturnType< + ReturnType["listPendingWrites"] + >; + committingBatchIds: string[]; + }; + refresh: () => void; + approveBatch: (batchId: string) => Promise; + rejectBatch: (batchId: string) => Promise; + }; + } +} diff --git a/apps/obsidian/src/services/mcpBridge.types.ts b/apps/obsidian/src/services/mcpBridge.types.ts new file mode 100644 index 000000000..bda4a4238 --- /dev/null +++ b/apps/obsidian/src/services/mcpBridge.types.ts @@ -0,0 +1,45 @@ +export const DEFAULT_MCP_BRIDGE_PORT = 3598; +export const MCP_BRIDGE_HEALTH_PATH = "/health"; +export const MCP_BRIDGE_CONTEXT_PATH = "/context"; +export const MCP_BRIDGE_NODE_TYPES_PATH = "/node-types"; +export const MCP_BRIDGE_RELATION_TYPES_PATH = "/relation-types"; +export const MCP_BRIDGE_DISCOURSE_RELATIONS_PATH = "/discourse-relations"; +export const MCP_BRIDGE_SEARCH_PATH = "/search"; +export const MCP_BRIDGE_SERVICE_NAME = "dg-obsidian-mcp-bridge"; +export const MCP_BRIDGE_VERSION = "1"; + +export type McpBridgeHealth = { + ok: true; + service: typeof MCP_BRIDGE_SERVICE_NAME; + version: typeof MCP_BRIDGE_VERSION; + port: number; + vaultId: string; + vaultName: string; +}; + +export type McpBridgeContext = { + platform: "Obsidian"; + vaultId: string; + vaultName: string; + syncEnabled: boolean; + spaceId?: number; + spaceUrl?: string; + spacePassword?: string; +}; + +export const buildMcpBridgeHealth = ({ + port, + vaultId, + vaultName, +}: { + port: number; + vaultId: string; + vaultName: string; +}): McpBridgeHealth => ({ + ok: true, + service: MCP_BRIDGE_SERVICE_NAME, + version: MCP_BRIDGE_VERSION, + port, + vaultId, + vaultName, +}); diff --git a/apps/obsidian/src/services/mcpBridgeHttp.ts b/apps/obsidian/src/services/mcpBridgeHttp.ts new file mode 100644 index 000000000..b4d48e446 --- /dev/null +++ b/apps/obsidian/src/services/mcpBridgeHttp.ts @@ -0,0 +1,293 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + MCP_BRIDGE_CONTEXT_PATH, + MCP_BRIDGE_DISCOURSE_RELATIONS_PATH, + MCP_BRIDGE_HEALTH_PATH, + MCP_BRIDGE_NODE_TYPES_PATH, + MCP_BRIDGE_RELATION_TYPES_PATH, + MCP_BRIDGE_SEARCH_PATH, + type McpBridgeContext, + type McpBridgeHealth, +} from "./mcpBridge.types.js"; +import type { McpBridgeReadApi } from "./mcpBridgeRead.js"; +import type { + McpBridgeNodeContextResponse, + McpBridgeNodePayload, + McpBridgeRelationPayload, + McpBridgeSearchResponse, +} from "./mcpBridgeRead.types.js"; +import type { McpBridgeWriteApi } from "./mcpBridgeWrite.js"; +import { + MCP_BRIDGE_PENDING_WRITES_PATH, + MCP_BRIDGE_PROPOSE_WRITE_PATH, +} from "./mcpBridgeWrite.types.js"; +import { + parseClearWriteBody, + parseProposeWriteBody, +} from "./mcpBridgeWrite.js"; + +const bridgeBaseUrl = (port: number): string => `http://127.0.0.1:${port}`; + +const writeJson = ( + response: ServerResponse, + statusCode: number, + payload: unknown, +): void => { + response.writeHead(statusCode, { + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/json", + }); + response.end(JSON.stringify(payload)); +}; + +const writeNoContent = (response: ServerResponse): void => { + response.writeHead(204, { + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + "Access-Control-Allow-Origin": "*", + }); + response.end(); +}; + +const readJsonBody = async ( + request: IncomingMessage, +): Promise | null> => { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + if (chunks.length === 0) { + return {}; + } + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + return null; + } catch { + return null; + } +}; + +const parseNodeIdFromPath = (pathname: string): string | null => { + const match = /^\/node\/([^/]+)$/.exec(pathname); + return match?.[1] ?? null; +}; + +const parseNodeSubresourcePath = ( + pathname: string, + suffix: "relations" | "context", +): string | null => { + const match = new RegExp(`^/node/([^/]+)/${suffix}$`).exec(pathname); + return match?.[1] ?? null; +}; + +const parsePendingWriteIdFromPath = (pathname: string): string | null => { + const match = /^\/pending-write\/([^/]+)$/.exec(pathname); + return match?.[1] ?? null; +}; + +const parseClearWriteIdFromPath = (pathname: string): string | null => { + const match = /^\/clear-write\/([^/]+)$/.exec(pathname); + return match?.[1] ?? null; +}; + +export type McpBridgeRequestHandler = { + getHealth: () => McpBridgeHealth; + getContext: () => Promise; + read: McpBridgeReadApi; + write: McpBridgeWriteApi; +}; + +export const handleMcpBridgeHttpRequest = async ( + request: IncomingMessage, + response: ServerResponse, + handler: McpBridgeRequestHandler, +): Promise => { + if (!request.url) { + writeNoContent(response); + return; + } + + if (request.method === "OPTIONS") { + writeNoContent(response); + return; + } + + const url = new URL( + request.url, + bridgeBaseUrl(handler.getHealth().port), + ); + + if (request.method === "GET" && url.pathname === MCP_BRIDGE_HEALTH_PATH) { + writeJson(response, 200, handler.getHealth()); + return; + } + + if (request.method === "GET" && url.pathname === MCP_BRIDGE_CONTEXT_PATH) { + const context = await handler.getContext(); + writeJson(response, 200, context); + return; + } + + if ( + request.method === "GET" && + url.pathname === MCP_BRIDGE_PENDING_WRITES_PATH + ) { + writeJson(response, 200, { + batches: handler.write.listPendingWrites(), + pendingCount: handler.write.pendingCount(), + }); + return; + } + + if ( + request.method === "POST" && + url.pathname === MCP_BRIDGE_PROPOSE_WRITE_PATH + ) { + const body = await readJsonBody(request); + if (body === null) { + writeJson(response, 400, { ok: false, error: "Invalid JSON body" }); + return; + } + + const input = parseProposeWriteBody(body); + if (!input) { + writeJson(response, 400, { + ok: false, + error: + "Invalid propose-write payload. Expected non-empty operations[].", + }); + return; + } + + const result = handler.write.proposeWrite(input); + writeJson(response, 200, result); + return; + } + + const pendingWriteId = parsePendingWriteIdFromPath(url.pathname); + if (request.method === "GET" && pendingWriteId) { + const pending = handler.write.getPendingWrite(pendingWriteId); + if (!pending.batch) { + writeJson(response, 404, { + ok: false, + error: `Write batch not found: ${pendingWriteId}`, + }); + return; + } + writeJson(response, 200, pending); + return; + } + + const clearWriteId = parseClearWriteIdFromPath(url.pathname); + if (request.method === "POST" && clearWriteId) { + const body = await readJsonBody(request); + if (body === null) { + writeJson(response, 400, { ok: false, error: "Invalid JSON body" }); + return; + } + + const resolution = parseClearWriteBody(body); + const result = handler.write.clearWrite(clearWriteId, resolution); + if (!result.cleared) { + writeJson(response, 404, { + ok: false, + cleared: false, + error: result.error, + }); + return; + } + + writeJson(response, 200, result); + return; + } + + if (request.method === "GET" && url.pathname === MCP_BRIDGE_NODE_TYPES_PATH) { + writeJson(response, 200, { nodeTypes: handler.read.getNodeTypes() }); + return; + } + + if ( + request.method === "GET" && + url.pathname === MCP_BRIDGE_RELATION_TYPES_PATH + ) { + writeJson(response, 200, { + relationTypes: handler.read.getRelationTypes(), + }); + return; + } + + if ( + request.method === "GET" && + url.pathname === MCP_BRIDGE_DISCOURSE_RELATIONS_PATH + ) { + writeJson(response, 200, { + discourseRelations: handler.read.getDiscourseRelations(), + }); + return; + } + + if (request.method === "POST" && url.pathname === MCP_BRIDGE_SEARCH_PATH) { + const body = await readJsonBody(request); + if (body === null) { + writeJson(response, 400, { ok: false, error: "Invalid JSON body" }); + return; + } + + const query = typeof body.query === "string" ? body.query : ""; + const nodeTypeId = + typeof body.nodeTypeId === "string" ? body.nodeTypeId : undefined; + const results: McpBridgeSearchResponse = await handler.read.searchNodes({ + query, + nodeTypeId, + }); + writeJson(response, 200, results); + return; + } + + const nodeId = parseNodeIdFromPath(url.pathname); + if (request.method === "GET" && nodeId) { + const node: McpBridgeNodePayload | null = await handler.read.getNode(nodeId); + if (!node) { + writeJson(response, 404, { + ok: false, + error: `Node not found: ${nodeId}`, + }); + return; + } + writeJson(response, 200, { node }); + return; + } + + const relationsNodeId = parseNodeSubresourcePath(url.pathname, "relations"); + if (request.method === "GET" && relationsNodeId) { + const relations: McpBridgeRelationPayload[] = + await handler.read.getNodeRelations(relationsNodeId); + writeJson(response, 200, { relations }); + return; + } + + const contextNodeId = parseNodeSubresourcePath(url.pathname, "context"); + if (request.method === "GET" && contextNodeId) { + const context: McpBridgeNodeContextResponse | null = + await handler.read.getNodeContext(contextNodeId); + if (!context) { + writeJson(response, 404, { + ok: false, + error: `Node not found: ${contextNodeId}`, + }); + return; + } + writeJson(response, 200, context); + return; + } + + writeJson(response, 404, { + ok: false, + error: `Unknown route: ${request.method} ${url.pathname}`, + }); +}; diff --git a/apps/obsidian/src/services/mcpBridgeRead.ts b/apps/obsidian/src/services/mcpBridgeRead.ts new file mode 100644 index 000000000..18a732bd6 --- /dev/null +++ b/apps/obsidian/src/services/mcpBridgeRead.ts @@ -0,0 +1,229 @@ +import type { TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { + DiscourseRelation, + DiscourseRelationType, + RelationInstance, +} from "~/types"; +import { getFrontmatterForFile } from "~/components/canvas/shapes/discourseNodeShapeUtils"; +import { QueryEngine } from "~/services/QueryEngine"; +import { + getRelationsForFile, + resolveEndpointToFile, +} from "~/utils/relationsStore"; +import { ensureNodeInstanceId } from "~/utils/nodeInstanceId"; +import type { + McpBridgeNodeContextResponse, + McpBridgeNodePayload, + McpBridgeNodeSummary, + McpBridgeRelationPayload, + McpBridgeSearchResponse, +} from "./mcpBridgeRead.types.js"; + +const MIN_SEARCH_LENGTH = 2; + +const mapRelation = (relation: RelationInstance): McpBridgeRelationPayload => ({ + id: relation.id, + type: relation.type, + source: relation.source, + destination: relation.destination, + created: relation.created, + lastModified: relation.lastModified, + importedFromRid: relation.importedFromRid, + tentative: relation.tentative, +}); + +const mapFileToSummary = async ( + plugin: DiscourseGraphPlugin, + file: TFile, +): Promise => { + const frontmatter = getFrontmatterForFile(plugin.app, file); + const nodeTypeId = frontmatter?.nodeTypeId; + if (typeof nodeTypeId !== "string" || !nodeTypeId) { + return null; + } + + const nodeInstanceId = await ensureNodeInstanceId( + plugin, + file, + (frontmatter ?? {}) as Record, + ); + + return { + id: nodeInstanceId, + nodeTypeId, + title: file.basename, + path: file.path, + }; +}; + +const mapFileToNodePayload = async ( + plugin: DiscourseGraphPlugin, + file: TFile, +): Promise => { + const summary = await mapFileToSummary(plugin, file); + if (!summary) { + return null; + } + + const frontmatter = getFrontmatterForFile(plugin.app, file) ?? {}; + const body = await plugin.app.vault.cachedRead(file); + + return { + ...summary, + body, + created: file.stat?.ctime, + modified: file.stat?.mtime, + importedFromRid: + typeof frontmatter.importedFromRid === "string" + ? frontmatter.importedFromRid + : undefined, + frontmatter: frontmatter as Record, + }; +}; + +const searchNodesInVault = ( + plugin: DiscourseGraphPlugin, + query: string, + nodeTypeId?: string, +): TFile[] => { + const queryEngine = new QueryEngine(plugin.app); + const fromDatacore = queryEngine.searchDiscourseNodesByTitle(query, nodeTypeId); + if (fromDatacore.length > 0) { + return fromDatacore; + } + + const normalizedQuery = query.toLowerCase(); + return queryEngine + .getFilesWithNodeTypeId() + .filter((file) => { + if (nodeTypeId) { + const fm = getFrontmatterForFile(plugin.app, file); + if (fm?.nodeTypeId !== nodeTypeId) { + return false; + } + } + return file.basename.toLowerCase().includes(normalizedQuery); + }); +}; + +const resolveNodeFile = ( + plugin: DiscourseGraphPlugin, + nodeId: string, +): TFile | null => { + const queryEngine = new QueryEngine(plugin.app); + return queryEngine.getFileByEndpoint(nodeId) ?? resolveEndpointToFile(plugin, nodeId); +}; + +export type McpBridgeReadApi = { + getNodeTypes: () => Array<{ + id: string; + name: string; + format: string; + description?: string; + color?: string; + tag?: string; + }>; + getRelationTypes: () => DiscourseRelationType[]; + getDiscourseRelations: () => DiscourseRelation[]; + searchNodes: (params: { + query: string; + nodeTypeId?: string; + }) => Promise; + getNode: (nodeId: string) => Promise; + getNodeRelations: (nodeId: string) => Promise; + getNodeContext: ( + nodeId: string, + ) => Promise; +}; + +export const createMcpBridgeReadApi = ( + plugin: DiscourseGraphPlugin, +): McpBridgeReadApi => ({ + getNodeTypes: () => + plugin.settings.nodeTypes.map((nodeType) => ({ + id: nodeType.id, + name: nodeType.name, + format: nodeType.format, + description: nodeType.description, + color: nodeType.color, + tag: nodeType.tag, + })), + + getRelationTypes: () => plugin.settings.relationTypes, + + getDiscourseRelations: () => plugin.settings.discourseRelations, + + searchNodes: async ({ query, nodeTypeId }) => { + if (!query || query.length < MIN_SEARCH_LENGTH) { + return { nodes: [] }; + } + + const files = searchNodesInVault(plugin, query, nodeTypeId); + const nodes: McpBridgeNodeSummary[] = []; + + for (const file of files) { + const summary = await mapFileToSummary(plugin, file); + if (summary) { + nodes.push(summary); + } + } + + return { nodes }; + }, + + getNode: async (nodeId) => { + const file = resolveNodeFile(plugin, nodeId); + if (!file) { + return null; + } + return mapFileToNodePayload(plugin, file); + }, + + getNodeRelations: async (nodeId) => { + const file = resolveNodeFile(plugin, nodeId); + if (!file) { + return []; + } + const relations = await getRelationsForFile(plugin, file); + return relations.map(mapRelation); + }, + + getNodeContext: async (nodeId) => { + const file = resolveNodeFile(plugin, nodeId); + if (!file) { + return null; + } + + const node = await mapFileToNodePayload(plugin, file); + if (!node) { + return null; + } + + const relations = (await getRelationsForFile(plugin, file)).map(mapRelation); + const endpointIds = new Set(); + for (const relation of relations) { + endpointIds.add(relation.source); + endpointIds.add(relation.destination); + } + endpointIds.delete(node.id); + + const relatedNodes: McpBridgeNodeSummary[] = []; + for (const endpointId of endpointIds) { + const relatedFile = resolveEndpointToFile(plugin, endpointId); + if (!relatedFile) { + continue; + } + const summary = await mapFileToSummary(plugin, relatedFile); + if (summary) { + relatedNodes.push(summary); + } + } + + return { + node, + relations, + relatedNodes, + }; + }, +}); diff --git a/apps/obsidian/src/services/mcpBridgeRead.types.ts b/apps/obsidian/src/services/mcpBridgeRead.types.ts new file mode 100644 index 000000000..594ef9c56 --- /dev/null +++ b/apps/obsidian/src/services/mcpBridgeRead.types.ts @@ -0,0 +1,40 @@ +export const MCP_BRIDGE_NODE_TYPES_PATH = "/node-types"; +export const MCP_BRIDGE_RELATION_TYPES_PATH = "/relation-types"; +export const MCP_BRIDGE_DISCOURSE_RELATIONS_PATH = "/discourse-relations"; +export const MCP_BRIDGE_SEARCH_PATH = "/search"; + +export type McpBridgeNodeSummary = { + id: string; + nodeTypeId: string; + title: string; + path: string; +}; + +export type McpBridgeNodePayload = McpBridgeNodeSummary & { + body: string; + created?: number; + modified?: number; + importedFromRid?: string; + frontmatter: Record; +}; + +export type McpBridgeRelationPayload = { + id: string; + type: string; + source: string; + destination: string; + created?: number; + lastModified?: number; + importedFromRid?: string; + tentative?: boolean; +}; + +export type McpBridgeSearchResponse = { + nodes: McpBridgeNodeSummary[]; +}; + +export type McpBridgeNodeContextResponse = { + node: McpBridgeNodePayload; + relations: McpBridgeRelationPayload[]; + relatedNodes: McpBridgeNodeSummary[]; +}; diff --git a/apps/obsidian/src/services/mcpBridgeWrite.ts b/apps/obsidian/src/services/mcpBridgeWrite.ts new file mode 100644 index 000000000..e74c49bc8 --- /dev/null +++ b/apps/obsidian/src/services/mcpBridgeWrite.ts @@ -0,0 +1,202 @@ +import type { McpBridgeWriteStore } from "./mcpBridgeWriteStore.js"; +import type { + McpBridgeClearWriteResponse, + McpBridgePendingWriteBatch, + McpBridgePendingWriteResponse, + McpBridgeProposeWriteResponse, + McpBridgeWriteBatchStatus, + McpBridgeWriteOperation, +} from "./mcpBridgeWrite.types.js"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const parseCreateNodeOperation = ( + value: Record, +): McpBridgeWriteOperation | null => { + if ( + value.kind !== "create_node" || + typeof value.nodeTypeId !== "string" || + typeof value.content !== "string" || + value.content.trim() === "" + ) { + return null; + } + + return { + kind: "create_node", + nodeTypeId: value.nodeTypeId, + content: value.content, + body: typeof value.body === "string" ? value.body : undefined, + }; +}; + +const parseUpdateNodeOperation = ( + value: Record, +): McpBridgeWriteOperation | null => { + if (value.kind !== "update_node") { + return null; + } + + const nodeInstanceId = + typeof value.nodeInstanceId === "string" + ? value.nodeInstanceId + : typeof value.nodeId === "string" + ? value.nodeId + : undefined; + const filename = + typeof value.filename === "string" + ? value.filename + : typeof value.path === "string" + ? value.path + : undefined; + + if (!nodeInstanceId && !filename) { + return null; + } + + const title = typeof value.title === "string" ? value.title : undefined; + const content = + typeof value.content === "string" + ? value.content + : typeof value.body === "string" + ? value.body + : undefined; + + if (title === undefined && content === undefined) { + return null; + } + + return { + kind: "update_node", + ...(nodeInstanceId ? { nodeInstanceId } : {}), + ...(filename ? { filename } : {}), + title, + content, + }; +}; + +const parseCreateRelationOperation = ( + value: Record, +): McpBridgeWriteOperation | null => { + if ( + value.kind !== "create_relation" || + typeof value.sourceId !== "string" || + typeof value.destinationId !== "string" || + typeof value.relationTypeId !== "string" + ) { + return null; + } + + return { + kind: "create_relation", + sourceId: value.sourceId, + destinationId: value.destinationId, + relationTypeId: value.relationTypeId, + }; +}; + +const parseWriteOperation = (value: unknown): McpBridgeWriteOperation | null => { + if (!isRecord(value)) { + return null; + } + + switch (value.kind) { + case "create_node": + return parseCreateNodeOperation(value); + case "update_node": + return parseUpdateNodeOperation(value); + case "create_relation": + return parseCreateRelationOperation(value); + default: + return null; + } +}; + +export const parseProposeWriteBody = ( + body: Record | null, +): { operations: McpBridgeWriteOperation[]; label?: string } | null => { + if (!body || !Array.isArray(body.operations) || body.operations.length === 0) { + return null; + } + + const operations: McpBridgeWriteOperation[] = []; + for (const operation of body.operations) { + const parsed = parseWriteOperation(operation); + if (!parsed) { + return null; + } + operations.push(parsed); + } + + return { + operations, + label: typeof body.label === "string" ? body.label : undefined, + }; +}; + +export const parseClearWriteBody = ( + body: Record | null, +): Extract | undefined => { + if (!body) { + return undefined; + } + if (body.resolution === "approved" || body.resolution === "rejected") { + return body.resolution; + } + return undefined; +}; + +export type McpBridgeWriteApi = { + proposeWrite: (input: { + operations: McpBridgeWriteOperation[]; + label?: string; + }) => McpBridgeProposeWriteResponse; + getPendingWrite: (batchId: string) => McpBridgePendingWriteResponse; + listPendingWrites: () => McpBridgePendingWriteBatch[]; + clearWrite: ( + batchId: string, + resolution?: Extract, + ) => McpBridgeClearWriteResponse; + pendingCount: () => number; +}; + +export const createMcpBridgeWriteApi = ( + store: McpBridgeWriteStore, +): McpBridgeWriteApi => ({ + proposeWrite: ({ operations, label }) => { + const batch = store.propose({ operations, label }); + return { + ok: true, + batchId: batch.batchId, + status: "pending", + createdAt: batch.createdAt, + expiresAt: batch.expiresAt, + operationCount: batch.operations.length, + }; + }, + + getPendingWrite: (batchId) => ({ + batch: store.getBatch(batchId), + }), + + listPendingWrites: () => store.listPending(), + + clearWrite: (batchId, resolution) => { + const cleared = store.clear(batchId, resolution); + if (!cleared) { + return { + cleared: false, + batch: null, + error: `No write batch matched batchId ${batchId}.`, + }; + } + + return { + cleared: true, + batch: cleared, + }; + }, + + pendingCount: () => store.pendingCount(), +}); diff --git a/apps/obsidian/src/services/mcpBridgeWrite.types.ts b/apps/obsidian/src/services/mcpBridgeWrite.types.ts new file mode 100644 index 000000000..d71720192 --- /dev/null +++ b/apps/obsidian/src/services/mcpBridgeWrite.types.ts @@ -0,0 +1,111 @@ +export const MCP_BRIDGE_PROPOSE_WRITE_PATH = "/propose-write"; +export const MCP_BRIDGE_PENDING_WRITES_PATH = "/pending-writes"; + +export const DEFAULT_WRITE_BATCH_TTL_MS = 60 * 60 * 1000; + +export type McpBridgeWriteOperation = + | { + kind: "create_node"; + nodeTypeId: string; + content: string; + body?: string; + } + | { + kind: "update_node"; + /** Resolve target by frontmatter `nodeInstanceId`. */ + nodeInstanceId?: string; + /** Resolve target by vault path or note basename (with or without `.md`). */ + filename?: string; + /** New discourse title text (`{content}` slot); renames the note file. */ + title?: string; + /** New markdown body for the note file. */ + content?: string; + } + | { + kind: "create_relation"; + sourceId: string; + destinationId: string; + relationTypeId: string; + }; + +export type McpBridgeWriteBatchStatus = "pending" | "approved" | "rejected"; + +export type McpBridgePendingWriteBatch = { + batchId: string; + status: "pending"; + createdAt: string; + expiresAt: string; + label?: string; + operations: McpBridgeWriteOperation[]; +}; + +export type McpBridgeResolvedWriteBatch = { + batchId: string; + status: "approved" | "rejected"; + createdAt: string; + resolvedAt: string; + label?: string; + operations: McpBridgeWriteOperation[]; +}; + +export type McpBridgeWriteBatch = + | McpBridgePendingWriteBatch + | McpBridgeResolvedWriteBatch; + +export type McpBridgeProposeWriteResponse = { + ok: true; + batchId: string; + status: "pending"; + createdAt: string; + expiresAt: string; + operationCount: number; +}; + +export type McpBridgePendingWriteResponse = { + batch: McpBridgeWriteBatch | null; +}; + +export type McpBridgeClearWriteResponse = { + cleared: boolean; + batch: McpBridgeWriteBatch | null; + error?: string; +}; + +export type WriteSchemaLabels = { + nodeTypes?: Array<{ id: string; name: string }>; + relationTypes?: Array<{ id: string; label: string }>; +}; + +export const describeWriteOperation = ( + operation: McpBridgeWriteOperation, + schema: WriteSchemaLabels = {}, +): string => { + switch (operation.kind) { + case "create_node": { + const nodeType = schema.nodeTypes?.find( + (type) => type.id === operation.nodeTypeId, + ); + const bodySuffix = operation.body ? " (+ body)" : ""; + return `Create ${nodeType?.name ?? "node"}: ${operation.content}${bodySuffix}`; + } + case "update_node": { + const target = operation.filename ?? operation.nodeInstanceId ?? "node"; + const parts: string[] = [`Update ${target}`]; + if (operation.title !== undefined) { + parts.push(`title โ†’ ${operation.title}`); + } + if (operation.content !== undefined) { + parts.push("body"); + } + return parts.join(" ยท "); + } + case "create_relation": { + const relationType = schema.relationTypes?.find( + (type) => type.id === operation.relationTypeId, + ); + return `Add ${relationType?.label ?? operation.relationTypeId}: ${operation.sourceId} โ†’ ${operation.destinationId}`; + } + default: + return "Unknown operation"; + } +}; diff --git a/apps/obsidian/src/services/mcpBridgeWriteExecutor.ts b/apps/obsidian/src/services/mcpBridgeWriteExecutor.ts new file mode 100644 index 000000000..3bf3dfc01 --- /dev/null +++ b/apps/obsidian/src/services/mcpBridgeWriteExecutor.ts @@ -0,0 +1,239 @@ +import { normalizePath, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { DiscourseNode } from "~/types"; +import { addRelationToRelationsJson } from "~/components/canvas/utils/relationJsonUtils"; +import { + createDiscourseNodeFile, + formatNodeName, +} from "~/utils/createNode"; +import { checkInvalidChars } from "~/utils/validateNodeType"; +import { + getNodeTypeIdForFile, + resolveEndpointToFile, +} from "~/utils/relationsStore"; +import type { McpBridgeWriteOperation } from "./mcpBridgeWrite.types.js"; + +export type WriteExecutionResult = + | { ok: true } + | { ok: false; error: string }; + +const getNodeTypeById = ( + plugin: DiscourseGraphPlugin, + nodeTypeId: string, +): DiscourseNode | null => + plugin.settings.nodeTypes.find((nodeType) => nodeType.id === nodeTypeId) ?? + null; + +const resolveUpdateTargetFile = ( + plugin: DiscourseGraphPlugin, + operation: Extract, +): TFile | null => { + if (operation.nodeInstanceId) { + return resolveEndpointToFile(plugin, operation.nodeInstanceId); + } + + if (!operation.filename) { + return null; + } + + const trimmed = operation.filename.trim(); + const asPath = trimmed.endsWith(".md") ? trimmed : `${trimmed}.md`; + const normalizedPath = normalizePath(asPath); + const byPath = plugin.app.vault.getAbstractFileByPath(normalizedPath); + if (byPath instanceof TFile) { + return byPath; + } + + const basename = normalizedPath.split("/").pop()?.replace(/\.md$/, "") ?? trimmed; + return plugin.app.metadataCache.getFirstLinkpathDest(basename, ""); +}; + +const renameDiscourseNode = async ( + plugin: DiscourseGraphPlugin, + file: TFile, + nodeType: DiscourseNode, + titleContent: string, +): Promise => { + const formattedNodeName = formatNodeName(titleContent, nodeType); + if (!formattedNodeName) { + throw new Error("Could not format discourse node title."); + } + + const validation = checkInvalidChars(formattedNodeName); + if (!validation.isValid) { + throw new Error(validation.error ?? "Invalid node title."); + } + + const folderPath = + nodeType.folderPath?.trim() || plugin.settings.nodesFolderPath.trim(); + const newPath = folderPath + ? normalizePath(`${folderPath}/${formattedNodeName}.md`) + : normalizePath( + file.parent?.path + ? `${file.parent.path}/${formattedNodeName}.md` + : `${formattedNodeName}.md`, + ); + + if (file.path === newPath) { + return file; + } + + const destinationFile = plugin.app.vault.getAbstractFileByPath(newPath); + if (destinationFile instanceof TFile && destinationFile.path !== file.path) { + throw new Error(`Destination file already exists at ${newPath}`); + } + + if (folderPath) { + const folderExists = plugin.app.vault.getAbstractFileByPath(folderPath); + if (!folderExists) { + await plugin.app.vault.createFolder(folderPath); + } + } + + await plugin.app.fileManager.renameFile(file, newPath); + const renamed = plugin.app.vault.getAbstractFileByPath(newPath); + if (!(renamed instanceof TFile)) { + throw new Error(`Renamed file not found at ${newPath}`); + } + return renamed; +}; + +const executeCreateNode = async ( + plugin: DiscourseGraphPlugin, + operation: Extract, +): Promise => { + const nodeType = getNodeTypeById(plugin, operation.nodeTypeId); + if (!nodeType) { + return { ok: false, error: `Unknown node type: ${operation.nodeTypeId}` }; + } + + const formattedNodeName = formatNodeName(operation.content, nodeType); + if (!formattedNodeName) { + return { ok: false, error: "Could not format node title." }; + } + + const validation = checkInvalidChars(formattedNodeName); + if (!validation.isValid) { + return { ok: false, error: validation.error ?? "Invalid node title." }; + } + + const file = await createDiscourseNodeFile({ + plugin, + formattedNodeName, + nodeType, + }); + + if (!file) { + return { ok: false, error: "Failed to create discourse node file." }; + } + + if (operation.body) { + await plugin.app.vault.process(file, () => operation.body ?? ""); + } + + return { ok: true }; +}; + +const executeUpdateNode = async ( + plugin: DiscourseGraphPlugin, + operation: Extract, +): Promise => { + const file = resolveUpdateTargetFile(plugin, operation); + if (!file) { + return { + ok: false, + error: "Could not resolve target node for update.", + }; + } + + let targetFile = file; + + if (operation.title !== undefined) { + const nodeTypeId = await getNodeTypeIdForFile(plugin, targetFile); + if (!nodeTypeId) { + return { ok: false, error: "Target file is not a discourse node." }; + } + + const nodeType = getNodeTypeById(plugin, nodeTypeId); + if (!nodeType) { + return { ok: false, error: `Unknown node type: ${nodeTypeId}` }; + } + + try { + targetFile = await renameDiscourseNode( + plugin, + targetFile, + nodeType, + operation.title, + ); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + if (operation.content !== undefined) { + await plugin.app.vault.process(targetFile, () => operation.content ?? ""); + } + + return { ok: true }; +}; + +const executeCreateRelation = async ( + plugin: DiscourseGraphPlugin, + operation: Extract, +): Promise => { + const sourceFile = resolveEndpointToFile(plugin, operation.sourceId); + const destinationFile = resolveEndpointToFile(plugin, operation.destinationId); + + if (!sourceFile || !destinationFile) { + return { + ok: false, + error: "Could not resolve source or destination node for relation.", + }; + } + + const { alreadyExisted } = await addRelationToRelationsJson({ + plugin, + sourceFile, + targetFile: destinationFile, + relationTypeId: operation.relationTypeId, + }); + + if (alreadyExisted) { + return { ok: false, error: "Relation already exists." }; + } + + return { ok: true }; +}; + +export const executeWriteOperation = async ( + plugin: DiscourseGraphPlugin, + operation: McpBridgeWriteOperation, +): Promise => { + switch (operation.kind) { + case "create_node": + return executeCreateNode(plugin, operation); + case "update_node": + return executeUpdateNode(plugin, operation); + case "create_relation": + return executeCreateRelation(plugin, operation); + default: + return { ok: false, error: "Unknown write operation." }; + } +}; + +export const executeWriteBatch = async ( + plugin: DiscourseGraphPlugin, + operations: McpBridgeWriteOperation[], +): Promise => { + for (const operation of operations) { + const result = await executeWriteOperation(plugin, operation); + if (!result.ok) { + return result; + } + } + return { ok: true }; +}; diff --git a/apps/obsidian/src/services/mcpBridgeWriteStore.ts b/apps/obsidian/src/services/mcpBridgeWriteStore.ts new file mode 100644 index 000000000..396485ae7 --- /dev/null +++ b/apps/obsidian/src/services/mcpBridgeWriteStore.ts @@ -0,0 +1,128 @@ +import { + DEFAULT_WRITE_BATCH_TTL_MS, + type McpBridgePendingWriteBatch, + type McpBridgeResolvedWriteBatch, + type McpBridgeWriteBatch, + type McpBridgeWriteBatchStatus, + type McpBridgeWriteOperation, +} from "./mcpBridgeWrite.types.js"; + +const MAX_RESOLUTIONS = 50; + +export type McpBridgeWriteStore = { + propose: (input: { + operations: McpBridgeWriteOperation[]; + label?: string; + }) => McpBridgePendingWriteBatch; + getBatch: (batchId: string) => McpBridgeWriteBatch | null; + listPending: () => McpBridgePendingWriteBatch[]; + clear: ( + batchId: string, + resolution?: Extract, + ) => McpBridgeWriteBatch | null; + pendingCount: () => number; +}; + +const createBatchId = (): string => crypto.randomUUID(); + +export const createMcpBridgeWriteStore = ({ + ttlMs = DEFAULT_WRITE_BATCH_TTL_MS, + now = () => Date.now(), +}: { + ttlMs?: number; + now?: () => number; +} = {}): McpBridgeWriteStore => { + const pendingBatches = new Map(); + const recentResolutions = new Map(); + + const purgeExpired = (): void => { + const currentTime = now(); + for (const [batchId, batch] of pendingBatches.entries()) { + if (Date.parse(batch.expiresAt) <= currentTime) { + pendingBatches.delete(batchId); + } + } + }; + + const recordResolution = ( + batch: McpBridgePendingWriteBatch, + resolution: "approved" | "rejected", + ): McpBridgeResolvedWriteBatch => { + const resolved: McpBridgeResolvedWriteBatch = { + batchId: batch.batchId, + status: resolution, + createdAt: batch.createdAt, + resolvedAt: new Date(now()).toISOString(), + label: batch.label, + operations: batch.operations, + }; + + recentResolutions.set(batch.batchId, resolved); + if (recentResolutions.size > MAX_RESOLUTIONS) { + const oldest = recentResolutions.keys().next().value; + if (oldest) { + recentResolutions.delete(oldest); + } + } + + return resolved; + }; + + return { + propose: ({ operations, label }) => { + purgeExpired(); + const createdAt = new Date(now()).toISOString(); + const batch: McpBridgePendingWriteBatch = { + batchId: createBatchId(), + status: "pending", + createdAt, + expiresAt: new Date(now() + ttlMs).toISOString(), + label, + operations, + }; + pendingBatches.set(batch.batchId, batch); + return batch; + }, + + getBatch: (batchId) => { + purgeExpired(); + const pending = pendingBatches.get(batchId); + if (pending) { + return pending; + } + return recentResolutions.get(batchId) ?? null; + }, + + listPending: () => { + purgeExpired(); + return Array.from(pendingBatches.values()); + }, + + clear: (batchId, resolution) => { + purgeExpired(); + const pending = pendingBatches.get(batchId); + if (!pending) { + return recentResolutions.get(batchId) ?? null; + } + + pendingBatches.delete(batchId); + if (resolution) { + return recordResolution(pending, resolution); + } + + return { + batchId: pending.batchId, + status: "rejected", + createdAt: pending.createdAt, + resolvedAt: new Date(now()).toISOString(), + label: pending.label, + operations: pending.operations, + }; + }, + + pendingCount: () => { + purgeExpired(); + return pendingBatches.size; + }, + }; +}; diff --git a/apps/obsidian/src/services/mcpWriteApprovalService.ts b/apps/obsidian/src/services/mcpWriteApprovalService.ts new file mode 100644 index 000000000..f3808dd72 --- /dev/null +++ b/apps/obsidian/src/services/mcpWriteApprovalService.ts @@ -0,0 +1,238 @@ +import { Notice } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { McpBridgeWriteApi } from "./mcpBridgeWrite.js"; +import { describeWriteOperation } from "./mcpBridgeWrite.types.js"; +import type { McpBridgePendingWriteBatch } from "./mcpBridgeWrite.types.js"; +import { executeWriteBatch } from "./mcpBridgeWriteExecutor.js"; + +const POLL_MS = 1200; +const PILL_ID = "dg-mcp-write-pill"; +const PANEL_ID = "dg-mcp-write-panel"; + +export type McpWriteApprovalService = { + start: () => void; + stop: () => void; + refresh: () => void; + approveBatch: (batchId: string) => Promise; + rejectBatch: (batchId: string) => Promise; + getState: () => { + pendingCount: number; + pendingWrites: McpBridgePendingWriteBatch[]; + committingBatchIds: string[]; + }; +}; + +export const createMcpWriteApprovalService = ({ + plugin, + writeApi, +}: { + plugin: DiscourseGraphPlugin; + writeApi: McpBridgeWriteApi; +}): McpWriteApprovalService => { + let pollTimer: number | null = null; + let pollInFlight = false; + let panelOpen = false; + const committingBatchIds = new Set(); + + const getMountRoot = (): HTMLElement => activeDocument.body; + + const removeElement = (id: string): void => { + getMountRoot().querySelector(`#${id}`)?.remove(); + }; + + const renderPill = (batches: McpBridgePendingWriteBatch[]): void => { + let pill = getMountRoot().querySelector(`#${PILL_ID}`); + if (!pill) { + pill = activeDocument.createElement("button"); + pill.id = PILL_ID; + pill.type = "button"; + pill.className = "dg-mcp-write-pill"; + pill.addEventListener("click", () => { + panelOpen = !panelOpen; + render(); + }); + getMountRoot().appendChild(pill); + } + + const count = batches.length; + pill.hidden = count === 0 && !panelOpen; + pill.textContent = + count === 0 + ? "MCP writes" + : count === 1 + ? "1 MCP write pending" + : `${count} MCP writes pending`; + pill.setAttribute("aria-expanded", panelOpen ? "true" : "false"); + }; + + const renderPanel = (batches: McpBridgePendingWriteBatch[]): void => { + removeElement(PANEL_ID); + if (!panelOpen) { + return; + } + + const panel = activeDocument.createElement("div"); + panel.id = PANEL_ID; + panel.className = "dg-mcp-write-panel"; + + const header = activeDocument.createElement("div"); + header.className = "dg-mcp-write-panel__header"; + header.textContent = "Pending MCP writes"; + panel.appendChild(header); + + if (batches.length === 0) { + const empty = activeDocument.createElement("p"); + empty.className = "dg-mcp-write-panel__empty"; + empty.textContent = "No pending writes."; + panel.appendChild(empty); + } + + for (const batch of batches) { + const card = activeDocument.createElement("div"); + card.className = "dg-mcp-write-batch"; + card.dataset.batchId = batch.batchId; + + const title = activeDocument.createElement("div"); + title.className = "dg-mcp-write-batch__title"; + title.textContent = batch.label ?? `Batch ${batch.batchId.slice(0, 8)}`; + card.appendChild(title); + + const list = activeDocument.createElement("ul"); + list.className = "dg-mcp-write-batch__operations"; + for (const operation of batch.operations) { + const item = activeDocument.createElement("li"); + item.textContent = describeWriteOperation(operation, { + nodeTypes: plugin.settings.nodeTypes, + relationTypes: plugin.settings.relationTypes, + }); + list.appendChild(item); + } + card.appendChild(list); + + const actions = activeDocument.createElement("div"); + actions.className = "dg-mcp-write-batch__actions"; + + const approveButton = activeDocument.createElement("button"); + approveButton.type = "button"; + approveButton.className = "dg-mcp-write-btn dg-mcp-write-btn--approve"; + approveButton.textContent = "Approve"; + approveButton.disabled = committingBatchIds.has(batch.batchId); + approveButton.addEventListener("click", () => { + void approveBatch(batch.batchId); + }); + + const rejectButton = activeDocument.createElement("button"); + rejectButton.type = "button"; + rejectButton.className = "dg-mcp-write-btn dg-mcp-write-btn--reject"; + rejectButton.textContent = "Reject"; + rejectButton.disabled = committingBatchIds.has(batch.batchId); + rejectButton.addEventListener("click", () => { + void rejectBatch(batch.batchId); + }); + + actions.appendChild(approveButton); + actions.appendChild(rejectButton); + card.appendChild(actions); + panel.appendChild(card); + } + + getMountRoot().appendChild(panel); + }; + + const render = (): void => { + const batches = writeApi.listPendingWrites(); + renderPill(batches); + renderPanel(batches); + }; + + const approveBatch = async (batchId: string): Promise => { + if (committingBatchIds.has(batchId)) { + return; + } + + const response = writeApi.getPendingWrite(batchId); + const batch = response.batch; + if (!batch || batch.status !== "pending") { + return; + } + + committingBatchIds.add(batchId); + render(); + + try { + const result = await executeWriteBatch(plugin, batch.operations); + if (!result.ok) { + new Notice(`MCP write failed: ${result.error}`, 6000); + return; + } + + writeApi.clearWrite(batchId, "approved"); + new Notice("MCP write approved.", 4000); + } catch (error) { + console.error("[dg-mcp-bridge] Failed to approve write batch:", error); + new Notice( + `MCP write failed: ${error instanceof Error ? error.message : String(error)}`, + 6000, + ); + } finally { + committingBatchIds.delete(batchId); + render(); + } + }; + + const rejectBatch = async (batchId: string): Promise => { + if (committingBatchIds.has(batchId)) { + return; + } + + writeApi.clearWrite(batchId, "rejected"); + new Notice("MCP write rejected.", 3000); + render(); + }; + + const poll = (): void => { + if (pollInFlight || committingBatchIds.size > 0) { + return; + } + + pollInFlight = true; + try { + render(); + } finally { + pollInFlight = false; + } + }; + + return { + start: () => { + if (pollTimer !== null) { + return; + } + render(); + pollTimer = window.setInterval(poll, POLL_MS); + }, + + stop: () => { + if (pollTimer !== null) { + window.clearInterval(pollTimer); + pollTimer = null; + } + panelOpen = false; + removeElement(PILL_ID); + removeElement(PANEL_ID); + }, + + refresh: () => { + render(); + }, + + approveBatch, + rejectBatch, + + getState: () => ({ + pendingCount: writeApi.pendingCount(), + pendingWrites: writeApi.listPendingWrites(), + committingBatchIds: Array.from(committingBatchIds), + }), + }; +}; diff --git a/apps/obsidian/styles.css b/apps/obsidian/styles.css index 567e1539b..2db566be6 100644 --- a/apps/obsidian/styles.css +++ b/apps/obsidian/styles.css @@ -100,3 +100,100 @@ body.dg-hide-frontmatter-ids .metadata-property[data-property-key^="rel_" i] { border: none !important; box-shadow: none !important; } + +/* MCP write approval pill */ +.dg-mcp-write-pill { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 100; + padding: 8px 14px; + border: 1px solid var(--background-modifier-border); + border-radius: 999px; + background: var(--background-primary); + color: var(--text-normal); + font-size: 13px; + font-weight: 600; + box-shadow: var(--shadow-s); + cursor: pointer; +} + +.dg-mcp-write-pill:hover { + background: var(--background-modifier-hover); +} + +.dg-mcp-write-panel { + position: fixed; + bottom: 56px; + right: 16px; + z-index: 100; + width: min(420px, calc(100vw - 32px)); + max-height: min(60vh, 480px); + overflow: auto; + padding: 12px; + border: 1px solid var(--background-modifier-border); + border-radius: 10px; + background: var(--background-primary); + box-shadow: var(--shadow-l); +} + +.dg-mcp-write-panel__header { + font-size: 14px; + font-weight: 700; + margin-bottom: 10px; +} + +.dg-mcp-write-panel__empty { + margin: 0; + color: var(--text-muted); + font-size: 13px; +} + +.dg-mcp-write-batch { + padding: 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + background: var(--background-secondary); +} + +.dg-mcp-write-batch + .dg-mcp-write-batch { + margin-top: 10px; +} + +.dg-mcp-write-batch__title { + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.dg-mcp-write-batch__operations { + margin: 0 0 10px 18px; + padding: 0; + font-size: 12px; + color: var(--text-muted); +} + +.dg-mcp-write-batch__actions { + display: flex; + gap: 8px; +} + +.dg-mcp-write-btn { + padding: 4px 10px; + border-radius: 6px; + border: 1px solid var(--background-modifier-border); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.dg-mcp-write-btn--approve { + background: var(--color-green); + color: var(--text-on-accent); + border-color: transparent; +} + +.dg-mcp-write-btn--reject { + background: transparent; + color: var(--text-muted); +} diff --git a/apps/obsidian/tests/mcpBridge.test.ts b/apps/obsidian/tests/mcpBridge.test.ts new file mode 100644 index 000000000..bb418789c --- /dev/null +++ b/apps/obsidian/tests/mcpBridge.test.ts @@ -0,0 +1,229 @@ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import test from "node:test"; +import { handleMcpBridgeHttpRequest } from "../src/services/mcpBridgeHttp.js"; +import type { McpBridgeReadApi } from "../src/services/mcpBridgeRead.js"; +import { createMcpBridgeWriteApi } from "../src/services/mcpBridgeWrite.js"; +import { createMcpBridgeWriteStore } from "../src/services/mcpBridgeWriteStore.js"; +import { + MCP_BRIDGE_CONTEXT_PATH, + MCP_BRIDGE_DISCOURSE_RELATIONS_PATH, + MCP_BRIDGE_HEALTH_PATH, + MCP_BRIDGE_NODE_TYPES_PATH, + MCP_BRIDGE_RELATION_TYPES_PATH, + MCP_BRIDGE_SEARCH_PATH, + MCP_BRIDGE_SERVICE_NAME, + buildMcpBridgeHealth, + DEFAULT_MCP_BRIDGE_PORT, + type McpBridgeContext, + type McpBridgeHealth, +} from "../src/services/mcpBridge.types.js"; + +const listenOnRandomPort = async ( + handler: Parameters[2], +): Promise<{ server: Server; port: number; baseUrl: string }> => + new Promise((resolve, reject) => { + const server = createServer((request, response) => { + void handleMcpBridgeHttpRequest(request, response, handler); + }); + + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (typeof address !== "object" || !address?.port) { + reject(new Error("Server did not receive a TCP port.")); + return; + } + resolve({ + server, + port: address.port, + baseUrl: `http://127.0.0.1:${address.port}`, + }); + }); + }); + +const closeServer = async (server: Server): Promise => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + +const healthFixture: McpBridgeHealth = buildMcpBridgeHealth({ + port: DEFAULT_MCP_BRIDGE_PORT, + vaultId: "vault-test-id", + vaultName: "Test Vault", +}); + +const contextFixture: McpBridgeContext = { + platform: "Obsidian", + vaultId: "vault-test-id", + vaultName: "Test Vault", + syncEnabled: true, + spaceId: 7, + spaceUrl: "obsidian:vault-test-id", +}; + +const readFixture: McpBridgeReadApi = { + getNodeTypes: () => [ + { id: "claim", name: "Claim", format: "CLM - {content}" }, + ], + getRelationTypes: () => [ + { + id: "supports", + label: "supports", + complement: "is supported by", + color: "green", + created: 0, + modified: 0, + }, + ], + getDiscourseRelations: () => [ + { + id: "rel1", + sourceId: "evidence", + destinationId: "claim", + relationshipTypeId: "supports", + created: 0, + modified: 0, + }, + ], + searchNodes: async () => ({ + nodes: [ + { + id: "node-1", + nodeTypeId: "claim", + title: "CLM - Example", + path: "nodes/CLM - Example.md", + }, + ], + }), + getNode: async (nodeId) => + nodeId === "node-1" + ? { + id: "node-1", + nodeTypeId: "claim", + title: "CLM - Example", + path: "nodes/CLM - Example.md", + body: "Example body", + frontmatter: { nodeTypeId: "claim", nodeInstanceId: "node-1" }, + } + : null, + getNodeRelations: async () => [], + getNodeContext: async (nodeId) => { + const node = await readFixture.getNode(nodeId); + if (!node) { + return null; + } + return { node, relations: [], relatedNodes: [] }; + }, +}; + +const writeFixture = createMcpBridgeWriteApi(createMcpBridgeWriteStore()); + +const handlerFixture = { + getHealth: () => healthFixture, + getContext: async () => contextFixture, + read: readFixture, + write: writeFixture, +}; + +test("GET /health returns bridge health payload", async (t) => { + const { server, baseUrl } = await listenOnRandomPort(handlerFixture); + t.after(() => closeServer(server)); + + const response = await fetch(`${baseUrl}${MCP_BRIDGE_HEALTH_PATH}`); + assert.equal(response.status, 200); + + const body = (await response.json()) as McpBridgeHealth; + assert.equal(body.ok, true); + assert.equal(body.service, MCP_BRIDGE_SERVICE_NAME); + assert.equal(body.vaultId, "vault-test-id"); +}); + +test("GET /context returns bridge context payload", async (t) => { + const { server, baseUrl } = await listenOnRandomPort(handlerFixture); + t.after(() => closeServer(server)); + + const response = await fetch(`${baseUrl}${MCP_BRIDGE_CONTEXT_PATH}`); + assert.equal(response.status, 200); + + const body = (await response.json()) as McpBridgeContext; + assert.equal(body.platform, "Obsidian"); + assert.equal(body.syncEnabled, true); + assert.equal(body.spaceId, 7); +}); + +test("GET /node-types returns configured node types", async (t) => { + const { server, baseUrl } = await listenOnRandomPort(handlerFixture); + t.after(() => closeServer(server)); + + const response = await fetch(`${baseUrl}${MCP_BRIDGE_NODE_TYPES_PATH}`); + const body = (await response.json()) as { + nodeTypes: Array<{ id: string; name: string }>; + }; + assert.equal(body.nodeTypes[0]?.name, "Claim"); +}); + +test("POST /search returns matching nodes", async (t) => { + const { server, baseUrl } = await listenOnRandomPort(handlerFixture); + t.after(() => closeServer(server)); + + const response = await fetch(`${baseUrl}${MCP_BRIDGE_SEARCH_PATH}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "Example" }), + }); + const body = (await response.json()) as { nodes: Array<{ id: string }> }; + assert.equal(body.nodes[0]?.id, "node-1"); +}); + +test("GET /node/:id returns node payload", async (t) => { + const { server, baseUrl } = await listenOnRandomPort(handlerFixture); + t.after(() => closeServer(server)); + + const response = await fetch(`${baseUrl}/node/node-1`); + const body = (await response.json()) as { node: { body: string } }; + assert.equal(body.node.body, "Example body"); +}); + +test("GET /node/:id returns 404 for missing node", async (t) => { + const { server, baseUrl } = await listenOnRandomPort(handlerFixture); + t.after(() => closeServer(server)); + + const response = await fetch(`${baseUrl}/node/missing`); + assert.equal(response.status, 404); +}); + +test("GET /relation-types and /discourse-relations return schema data", async (t) => { + const { server, baseUrl } = await listenOnRandomPort(handlerFixture); + t.after(() => closeServer(server)); + + const relationTypes = await fetch(`${baseUrl}${MCP_BRIDGE_RELATION_TYPES_PATH}`); + const discourseRelations = await fetch( + `${baseUrl}${MCP_BRIDGE_DISCOURSE_RELATIONS_PATH}`, + ); + + const relationBody = (await relationTypes.json()) as { + relationTypes: Array<{ id: string }>; + }; + const discourseBody = (await discourseRelations.json()) as { + discourseRelations: Array<{ id: string }>; + }; + + assert.equal(relationBody.relationTypes[0]?.id, "supports"); + assert.equal(discourseBody.discourseRelations[0]?.id, "rel1"); +}); + +test("unknown route returns 404", async (t) => { + const { server, baseUrl } = await listenOnRandomPort(handlerFixture); + t.after(() => closeServer(server)); + + const response = await fetch(`${baseUrl}/missing`); + assert.equal(response.status, 404); +}); diff --git a/apps/obsidian/tests/mcpBridgeWrite.test.ts b/apps/obsidian/tests/mcpBridgeWrite.test.ts new file mode 100644 index 000000000..8867309b6 --- /dev/null +++ b/apps/obsidian/tests/mcpBridgeWrite.test.ts @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import test from "node:test"; +import { handleMcpBridgeHttpRequest } from "../src/services/mcpBridgeHttp.js"; +import type { McpBridgeReadApi } from "../src/services/mcpBridgeRead.js"; +import { createMcpBridgeWriteApi, parseProposeWriteBody } from "../src/services/mcpBridgeWrite.js"; +import { createMcpBridgeWriteStore } from "../src/services/mcpBridgeWriteStore.js"; +import { + MCP_BRIDGE_PROPOSE_WRITE_PATH, + type McpBridgeWriteOperation, +} from "../src/services/mcpBridgeWrite.types.js"; +import { + MCP_BRIDGE_HEALTH_PATH, + buildMcpBridgeHealth, + DEFAULT_MCP_BRIDGE_PORT, + type McpBridgeHealth, +} from "../src/services/mcpBridge.types.js"; + +const sampleOperation: McpBridgeWriteOperation = { + kind: "create_node", + nodeTypeId: "claim", + content: "What is MCP?", + body: "Draft body", +}; + +const createHandlerFixture = () => { + const writeStore = createMcpBridgeWriteStore(); + const write = createMcpBridgeWriteApi(writeStore); + const health: McpBridgeHealth = buildMcpBridgeHealth({ + port: DEFAULT_MCP_BRIDGE_PORT, + vaultId: "vault-test-id", + vaultName: "Test Vault", + }); + + const read: McpBridgeReadApi = { + getNodeTypes: () => [], + getRelationTypes: () => [], + getDiscourseRelations: () => [], + searchNodes: async () => ({ nodes: [] }), + getNode: async () => null, + getNodeRelations: async () => [], + getNodeContext: async () => null, + }; + + return { + getHealth: () => health, + getContext: async () => ({ + platform: "Obsidian" as const, + vaultId: "vault-test-id", + vaultName: "Test Vault", + syncEnabled: false, + }), + read, + write, + }; +}; + +const listenOnRandomPort = async ( + handler: ReturnType, +): Promise<{ server: Server; baseUrl: string }> => + new Promise((resolve, reject) => { + const server = createServer((request, response) => { + void handleMcpBridgeHttpRequest(request, response, handler); + }); + + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + const address = server.address(); + if (typeof address !== "object" || !address?.port) { + reject(new Error("Server did not receive a TCP port.")); + return; + } + resolve({ + server, + baseUrl: `http://127.0.0.1:${address.port}`, + }); + }); + }); + +const closeServer = async (server: Server): Promise => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + +test("write store propose -> pending; clear -> gone", () => { + const store = createMcpBridgeWriteStore(); + const batch = store.propose({ operations: [sampleOperation] }); + + assert.equal(batch.status, "pending"); + assert.equal(store.pendingCount(), 1); + assert.equal(store.getBatch(batch.batchId)?.status, "pending"); + + const cleared = store.clear(batch.batchId, "rejected"); + assert.equal(cleared?.status, "rejected"); + assert.equal(store.pendingCount(), 0); + assert.equal(store.getBatch(batch.batchId)?.status, "rejected"); +}); + +test("write store expires pending batches after TTL", () => { + let current = 0; + const store = createMcpBridgeWriteStore({ + ttlMs: 1_000, + now: () => current, + }); + + const batch = store.propose({ operations: [sampleOperation] }); + assert.equal(store.pendingCount(), 1); + + current = 2_000; + assert.equal(store.getBatch(batch.batchId), null); + assert.equal(store.pendingCount(), 0); +}); + +test("POST /propose-write returns batchId and pending status", async (t) => { + const handler = createHandlerFixture(); + const { server, baseUrl } = await listenOnRandomPort(handler); + t.after(() => closeServer(server)); + + const response = await fetch(`${baseUrl}${MCP_BRIDGE_PROPOSE_WRITE_PATH}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + label: "create-question", + operations: [sampleOperation], + }), + }); + + assert.equal(response.status, 200); + const body = (await response.json()) as { + ok: true; + batchId: string; + status: string; + operationCount: number; + }; + assert.equal(body.ok, true); + assert.equal(body.status, "pending"); + assert.equal(body.operationCount, 1); + assert.equal(typeof body.batchId, "string"); +}); + +test("GET /pending-write/:id returns pending batch", async (t) => { + const handler = createHandlerFixture(); + const proposed = handler.write.proposeWrite({ operations: [sampleOperation] }); + const { server, baseUrl } = await listenOnRandomPort(handler); + t.after(() => closeServer(server)); + + const response = await fetch( + `${baseUrl}/pending-write/${proposed.batchId}`, + ); + const body = (await response.json()) as { + batch: { status: string; operations: McpBridgeWriteOperation[] }; + }; + + assert.equal(body.batch.status, "pending"); + assert.equal(body.batch.operations[0]?.kind, "create_node"); +}); + +test("POST /clear-write/:id removes pending batch", async (t) => { + const handler = createHandlerFixture(); + const proposed = handler.write.proposeWrite({ operations: [sampleOperation] }); + const { server, baseUrl } = await listenOnRandomPort(handler); + t.after(() => closeServer(server)); + + const clearResponse = await fetch( + `${baseUrl}/clear-write/${proposed.batchId}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ resolution: "rejected" }), + }, + ); + assert.equal(clearResponse.status, 200); + + const pendingResponse = await fetch( + `${baseUrl}/pending-write/${proposed.batchId}`, + ); + const pendingBody = (await pendingResponse.json()) as { + batch: { status: string }; + }; + assert.equal(pendingBody.batch.status, "rejected"); + assert.equal(handler.write.pendingCount(), 0); +}); + +test("parseProposeWriteBody accepts update_node by filename with title and content", () => { + const parsed = parseProposeWriteBody({ + operations: [ + { + kind: "update_node", + filename: "Discourse Nodes/CLM - Example.md", + title: "Revised claim text", + content: "Updated body", + }, + ], + }); + + assert.ok(parsed); + const operation = parsed.operations[0]; + assert.equal(operation?.kind, "update_node"); + if (operation?.kind !== "update_node") { + return; + } + assert.equal(operation.filename, "Discourse Nodes/CLM - Example.md"); + assert.equal(operation.title, "Revised claim text"); + assert.equal(operation.content, "Updated body"); +}); + +test("parseProposeWriteBody accepts update_node by nodeInstanceId with title only", () => { + const parsed = parseProposeWriteBody({ + operations: [ + { + kind: "update_node", + nodeInstanceId: "019e65a7-c7cb-70a5-87e1-340f2d6b9a70", + title: "New question wording", + }, + ], + }); + + assert.ok(parsed); + const operation = parsed.operations[0]; + assert.equal(operation?.kind, "update_node"); + if (operation?.kind !== "update_node") { + return; + } + assert.equal(operation.nodeInstanceId, "019e65a7-c7cb-70a5-87e1-340f2d6b9a70"); + assert.equal(operation.title, "New question wording"); + assert.equal(operation.content, undefined); +}); + +test("parseProposeWriteBody rejects update_node without target or fields", () => { + assert.equal( + parseProposeWriteBody({ + operations: [{ kind: "update_node", title: "Only title" }], + }), + null, + ); + assert.equal( + parseProposeWriteBody({ + operations: [ + { kind: "update_node", nodeInstanceId: "node-1" }, + ], + }), + null, + ); +}); + +test("POST /propose-write rejects invalid payload", async (t) => { + const handler = createHandlerFixture(); + const { server, baseUrl } = await listenOnRandomPort(handler); + t.after(() => closeServer(server)); + + const response = await fetch(`${baseUrl}${MCP_BRIDGE_PROPOSE_WRITE_PATH}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ operations: [] }), + }); + + assert.equal(response.status, 400); +}); diff --git a/apps/obsidian/tests/mcpBridgeWriteExecutor.test.ts b/apps/obsidian/tests/mcpBridgeWriteExecutor.test.ts new file mode 100644 index 000000000..409f44703 --- /dev/null +++ b/apps/obsidian/tests/mcpBridgeWriteExecutor.test.ts @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { describeWriteOperation } from "../src/services/mcpBridgeWrite.types.js"; + +const schema = { + nodeTypes: [{ id: "claim", name: "Claim" }], + relationTypes: [{ id: "supports", label: "supports" }], +}; + +test("describeWriteOperation summarizes create_node", () => { + const text = describeWriteOperation( + { + kind: "create_node", + nodeTypeId: "claim", + content: "What is MCP?", + body: "draft", + }, + schema, + ); + assert.match(text, /Create Claim/); + assert.match(text, /What is MCP\?/); +}); + +test("describeWriteOperation summarizes update_node title and body", () => { + const text = describeWriteOperation( + { + kind: "update_node", + nodeInstanceId: "node-1", + title: "New title", + content: "New body", + }, + schema, + ); + assert.match(text, /Update node-1/); + assert.match(text, /title/); + assert.match(text, /body/); +});