From d7f1fb31be375323211e90dbd381c120a6ef7daa Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 28 Feb 2026 20:11:52 +0100 Subject: [PATCH] feat: MCP server foundation with full tool surface and auto-registration - 14 MCP tools: vault CRUD, search, list, context, link, frontmatter edit, and 4 UI actions - Auto-registers Laputa in ~/.claude/mcp.json and ~/.cursor/mcp.json on startup - WebSocket bridge spawned on app start (port 9710 tools, 9711 UI actions) - Frontend useMcpRegistration hook for vault-aware registration - Comprehensive tests: 26 vault.js tests covering all 9 exported functions - Added gray-matter dependency for frontmatter parsing Co-Authored-By: Claude Opus 4.6 --- mcp-server/index.js | 180 ++++++++++++++++++++++++++- mcp-server/package-lock.json | 109 ++++++++++++++++ mcp-server/package.json | 1 + mcp-server/test.js | 160 +++++++++++++++++++++++- mcp-server/vault.js | 114 +++++++++++++++++ mcp-server/ws-bridge.js | 62 +++++++-- src-tauri/src/lib.rs | 54 +++++++- src-tauri/src/mcp.rs | 214 ++++++++++++++++++++++++++++++++ src/App.tsx | 2 + src/hooks/useMcpRegistration.ts | 33 +++++ src/mock-tauri/mock-handlers.ts | 1 + 11 files changed, 910 insertions(+), 20 deletions(-) create mode 100644 src-tauri/src/mcp.rs create mode 100644 src/hooks/useMcpRegistration.ts diff --git a/mcp-server/index.js b/mcp-server/index.js index 49e98bfa..08b4e323 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -6,11 +6,16 @@ * VAULT_PATH=/path/to/vault node index.js * * Tools: - * - open_note: Open and read a note by path - * - read_note: Read note content (alias for consistency) + * - open_note / read_note: Read a note by path * - create_note: Create a new note with title and optional frontmatter * - search_notes: Search notes by title or content * - append_to_note: Append text to an existing note + * - edit_note_frontmatter: Merge a patch into a note's YAML frontmatter + * - delete_note: Delete a note file + * - link_notes: Add a title to an array property in a note's frontmatter + * - list_notes: List all notes, optionally filtered by type + * - vault_context: Get vault types and recent notes + * - ui_open_note / ui_open_tab / ui_highlight / ui_set_filter: UI actions */ import { Server } from '@modelcontextprotocol/sdk/server/index.js' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' @@ -18,9 +23,24 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js' -import { readNote, createNote, searchNotes, appendToNote } from './vault.js' +import { + readNote, createNote, searchNotes, appendToNote, + editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext, +} from './vault.js' +import { startUiBridge } from './ws-bridge.js' const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa' +const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10) + +// Start the UI bridge so stdio-based MCP tools can broadcast UI actions +const uiBridge = startUiBridge(WS_UI_PORT) + +function broadcastUiAction(action, payload) { + const msg = JSON.stringify({ type: 'ui_action', action, ...payload }) + for (const client of uiBridge.clients) { + if (client.readyState === 1) client.send(msg) + } +} const TOOLS = [ { @@ -82,6 +102,103 @@ const TOOLS = [ required: ['path', 'text'], }, }, + { + name: 'edit_note_frontmatter', + description: 'Merge a patch object into a note\'s YAML frontmatter', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Relative path to the note' }, + patch: { type: 'object', description: 'Key-value pairs to merge into frontmatter' }, + }, + required: ['path', 'patch'], + }, + }, + { + name: 'delete_note', + description: 'Delete a note file from the vault', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Relative path to the note to delete' }, + }, + required: ['path'], + }, + }, + { + name: 'link_notes', + description: 'Add a target title to an array property in a note\'s frontmatter (e.g. add "Marco" to people: [])', + inputSchema: { + type: 'object', + properties: { + source_path: { type: 'string', description: 'Relative path to the source note' }, + property: { type: 'string', description: 'Frontmatter property name (e.g. "people", "tags")' }, + target_title: { type: 'string', description: 'Title to add to the array' }, + }, + required: ['source_path', 'property', 'target_title'], + }, + }, + { + name: 'list_notes', + description: 'List all notes in the vault, optionally filtered by type frontmatter field', + inputSchema: { + type: 'object', + properties: { + type_filter: { type: 'string', description: 'Filter by type frontmatter value' }, + sort: { type: 'string', enum: ['title', 'mtime'], description: 'Sort order (default: title)' }, + }, + }, + }, + { + name: 'vault_context', + description: 'Get vault context: unique entity types and 20 most recently modified notes', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'ui_open_note', + description: 'Open a note in the Laputa UI editor', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Relative path to the note' }, + }, + required: ['path'], + }, + }, + { + name: 'ui_open_tab', + description: 'Open a note in a new tab in the Laputa UI', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Relative path to the note' }, + }, + required: ['path'], + }, + }, + { + name: 'ui_highlight', + description: 'Highlight a UI element in the Laputa interface', + inputSchema: { + type: 'object', + properties: { + element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'UI element to highlight' }, + path: { type: 'string', description: 'Relative path to the note (optional)' }, + }, + required: ['element'], + }, + }, + { + name: 'ui_set_filter', + description: 'Set the sidebar filter to show notes of a specific type', + inputSchema: { + type: 'object', + properties: { + type: { type: 'string', description: 'Type to filter by' }, + }, + required: ['type'], + }, + }, ] const TOOL_HANDLERS = { @@ -90,6 +207,15 @@ const TOOL_HANDLERS = { create_note: handleCreateNote, search_notes: handleSearchNotes, append_to_note: handleAppendToNote, + edit_note_frontmatter: handleEditFrontmatter, + delete_note: handleDeleteNote, + link_notes: handleLinkNotes, + list_notes: handleListNotes, + vault_context: handleVaultContext, + ui_open_note: handleUiOpenNote, + ui_open_tab: handleUiOpenTab, + ui_highlight: handleUiHighlight, + ui_set_filter: handleUiSetFilter, } async function handleReadNote(args) { @@ -117,6 +243,54 @@ async function handleAppendToNote(args) { return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] } } +async function handleEditFrontmatter(args) { + const updated = await editNoteFrontmatter(VAULT_PATH, args.path, args.patch) + return { content: [{ type: 'text', text: JSON.stringify(updated) }] } +} + +async function handleDeleteNote(args) { + await deleteNote(VAULT_PATH, args.path) + return { content: [{ type: 'text', text: `Deleted ${args.path}` }] } +} + +async function handleLinkNotes(args) { + const arr = await linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title) + return { content: [{ type: 'text', text: `${args.property}: [${arr.join(', ')}]` }] } +} + +async function handleListNotes(args) { + const notes = await listNotes(VAULT_PATH, args.type_filter, args.sort) + const text = notes.length === 0 + ? 'No notes found.' + : notes.map(n => `${n.title} (${n.path})${n.type ? ` [${n.type}]` : ''}`).join('\n') + return { content: [{ type: 'text', text }] } +} + +async function handleVaultContext() { + const ctx = await vaultContext(VAULT_PATH) + return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] } +} + +function handleUiOpenNote(args) { + broadcastUiAction('open_note', { path: args.path }) + return { content: [{ type: 'text', text: `Opening ${args.path} in UI` }] } +} + +function handleUiOpenTab(args) { + broadcastUiAction('open_tab', { path: args.path }) + return { content: [{ type: 'text', text: `Opening tab for ${args.path}` }] } +} + +function handleUiHighlight(args) { + broadcastUiAction('highlight', { element: args.element, path: args.path }) + return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] } +} + +function handleUiSetFilter(args) { + broadcastUiAction('set_filter', { type: args.type }) + return { content: [{ type: 'text', text: `Filter set to ${args.type}` }] } +} + // --- Server setup --- const server = new Server( diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 6c22da69..7745fee9 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", + "gray-matter": "^4.0.3", "ws": "^8.19.0" } }, @@ -110,6 +111,15 @@ } } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -334,6 +344,19 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -425,6 +448,18 @@ "express": ">= 4.11" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -544,6 +579,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -637,6 +687,15 @@ "node": ">= 0.10" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -658,6 +717,19 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -670,6 +742,15 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -902,6 +983,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -1046,6 +1140,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1055,6 +1155,15 @@ "node": ">= 0.8" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/mcp-server/package.json b/mcp-server/package.json index a75e4a6e..5443a161 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", + "gray-matter": "^4.0.3", "ws": "^8.19.0" } } diff --git a/mcp-server/test.js b/mcp-server/test.js index b2184df7..e7e6eb40 100644 --- a/mcp-server/test.js +++ b/mcp-server/test.js @@ -3,14 +3,16 @@ import assert from 'node:assert/strict' import fs from 'node:fs/promises' import path from 'node:path' import os from 'node:os' -import { readNote, createNote, searchNotes, appendToNote, findMarkdownFiles } from './vault.js' +import { + readNote, createNote, searchNotes, appendToNote, findMarkdownFiles, + editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext, +} from './vault.js' let tmpDir before(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-')) - // Create test vault structure await fs.mkdir(path.join(tmpDir, 'project'), { recursive: true }) await fs.mkdir(path.join(tmpDir, 'note'), { recursive: true }) @@ -33,6 +35,19 @@ is_a: Note # Daily Log Today I worked on the MCP server implementation. +`) + + await fs.writeFile(path.join(tmpDir, 'project', 'second-project.md'), `--- +title: Second Project +type: Project +status: Draft +belongs_to: + - "[[project/test-project]]" +--- + +# Second Project + +Another project for testing list and context. `) }) @@ -43,9 +58,10 @@ after(async () => { describe('findMarkdownFiles', () => { it('should find all .md files recursively', async () => { const files = await findMarkdownFiles(tmpDir) - assert.equal(files.length, 2) + assert.equal(files.length, 3) assert.ok(files.some(f => f.endsWith('test-project.md'))) assert.ok(files.some(f => f.endsWith('daily-log.md'))) + assert.ok(files.some(f => f.endsWith('second-project.md'))) }) }) @@ -100,7 +116,7 @@ describe('searchNotes', () => { }) it('should respect limit', async () => { - const results = await searchNotes(tmpDir, 'note', 1) + const results = await searchNotes(tmpDir, 'project', 1) assert.ok(results.length <= 1) }) }) @@ -113,3 +129,139 @@ describe('appendToNote', () => { assert.ok(content.includes('Finished testing.')) }) }) + +describe('editNoteFrontmatter', () => { + it('should merge a patch into frontmatter', async () => { + const updated = await editNoteFrontmatter(tmpDir, 'project/test-project.md', { status: 'Completed', priority: 'High' }) + assert.equal(updated.status, 'Completed') + assert.equal(updated.priority, 'High') + assert.equal(updated.title, 'Test Project') + }) + + it('should preserve existing frontmatter fields', async () => { + const content = await readNote(tmpDir, 'project/test-project.md') + assert.ok(content.includes('is_a: Project')) + assert.ok(content.includes('status: Completed')) + }) + + it('should throw for missing file', async () => { + await assert.rejects( + () => editNoteFrontmatter(tmpDir, 'nonexistent.md', { foo: 'bar' }), + { code: 'ENOENT' } + ) + }) +}) + +describe('deleteNote', () => { + it('should delete an existing note', async () => { + const delPath = 'note/to-delete.md' + await createNote(tmpDir, delPath, 'To Delete') + const absPath = path.join(tmpDir, delPath) + + // Verify it exists + await fs.access(absPath) + + await deleteNote(tmpDir, delPath) + + await assert.rejects( + () => fs.access(absPath), + { code: 'ENOENT' } + ) + }) + + it('should throw for missing file', async () => { + await assert.rejects( + () => deleteNote(tmpDir, 'nonexistent.md'), + { code: 'ENOENT' } + ) + }) +}) + +describe('linkNotes', () => { + it('should add a target to an array property', async () => { + const linkPath = 'project/link-test.md' + await createNote(tmpDir, linkPath, 'Link Test', { is_a: 'Project' }) + + const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]') + assert.deepEqual(result, ['[[note/daily-log]]']) + }) + + it('should not duplicate existing links', async () => { + const linkPath = 'project/link-test.md' + + await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]') + const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]') + assert.equal(result.length, 1) + }) + + it('should add multiple distinct links', async () => { + const linkPath = 'project/link-test.md' + + await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]') + const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]') + // Should have daily-log and test-project + assert.ok(result.includes('[[note/daily-log]]')) + assert.ok(result.includes('[[project/test-project]]')) + assert.equal(result.length, 2) + }) +}) + +describe('listNotes', () => { + it('should list all notes sorted by title', async () => { + const notes = await listNotes(tmpDir) + assert.ok(notes.length >= 3) + // Verify sorted by title + for (let i = 1; i < notes.length; i++) { + assert.ok(notes[i - 1].title.localeCompare(notes[i].title) <= 0) + } + }) + + it('should filter by type', async () => { + const projects = await listNotes(tmpDir, 'Project') + assert.ok(projects.length >= 1) + for (const n of projects) { + assert.equal(n.type, 'Project') + } + }) + + it('should return empty for unknown type', async () => { + const notes = await listNotes(tmpDir, 'UnknownType12345') + assert.equal(notes.length, 0) + }) + + it('should support mtime sorting', async () => { + const notes = await listNotes(tmpDir, undefined, 'mtime') + assert.ok(notes.length >= 1) + // Just verify it returns results without crashing + assert.ok(notes[0].path) + assert.ok(notes[0].title) + }) +}) + +describe('vaultContext', () => { + it('should return types, recent notes, and vault path', async () => { + const ctx = await vaultContext(tmpDir) + assert.ok(Array.isArray(ctx.types)) + assert.ok(Array.isArray(ctx.recentNotes)) + assert.equal(ctx.vaultPath, tmpDir) + }) + + it('should include known entity types', async () => { + const ctx = await vaultContext(tmpDir) + assert.ok(ctx.types.includes('Project')) + assert.ok(ctx.types.includes('Note')) + }) + + it('should cap recent notes at 20', async () => { + const ctx = await vaultContext(tmpDir) + assert.ok(ctx.recentNotes.length <= 20) + }) + + it('should include path and title in recent notes', async () => { + const ctx = await vaultContext(tmpDir) + for (const note of ctx.recentNotes) { + assert.ok(note.path) + assert.ok(note.title) + } + }) +}) diff --git a/mcp-server/vault.js b/mcp-server/vault.js index 407ebbf6..ae191b2e 100644 --- a/mcp-server/vault.js +++ b/mcp-server/vault.js @@ -3,6 +3,7 @@ */ import fs from 'node:fs/promises' import path from 'node:path' +import matter from 'gray-matter' /** * Recursively find all .md files under a directory. @@ -104,6 +105,119 @@ export async function appendToNote(vaultPath, notePath, text) { await fs.writeFile(absPath, current + separator + text + '\n', 'utf-8') } +/** + * Merge a patch object into a note's YAML frontmatter. + * @param {string} vaultPath + * @param {string} notePath + * @param {Record} patch + * @returns {Promise>} The updated frontmatter. + */ +export async function editNoteFrontmatter(vaultPath, notePath, patch) { + const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath) + const raw = await fs.readFile(absPath, 'utf-8') + const parsed = matter(raw) + Object.assign(parsed.data, patch) + const updated = matter.stringify(parsed.content, parsed.data) + await fs.writeFile(absPath, updated, 'utf-8') + return parsed.data +} + +/** + * Delete a note file. + * @param {string} vaultPath + * @param {string} notePath + * @returns {Promise} + */ +export async function deleteNote(vaultPath, notePath) { + const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath) + await fs.unlink(absPath) +} + +/** + * Add a target title to an array property in a note's frontmatter. + * Creates the property as an array if it doesn't exist. + * @param {string} vaultPath + * @param {string} sourcePath + * @param {string} property + * @param {string} targetTitle + * @returns {Promise} The updated array. + */ +export async function linkNotes(vaultPath, sourcePath, property, targetTitle) { + const absPath = path.isAbsolute(sourcePath) ? sourcePath : path.join(vaultPath, sourcePath) + const raw = await fs.readFile(absPath, 'utf-8') + const parsed = matter(raw) + const current = Array.isArray(parsed.data[property]) ? parsed.data[property] : [] + if (!current.includes(targetTitle)) { + current.push(targetTitle) + } + parsed.data[property] = current + const updated = matter.stringify(parsed.content, parsed.data) + await fs.writeFile(absPath, updated, 'utf-8') + return current +} + +/** + * List all notes in the vault, optionally filtered by type. + * @param {string} vaultPath + * @param {string} [typeFilter] + * @param {string} [sort] - 'title' or 'mtime' (default: 'title') + * @returns {Promise>} + */ +export async function listNotes(vaultPath, typeFilter, sort = 'title') { + const files = await findMarkdownFiles(vaultPath) + const notes = await Promise.all(files.map(async (filePath) => { + const raw = await fs.readFile(filePath, 'utf-8') + const parsed = matter(raw) + const relativePath = path.relative(vaultPath, filePath) + const title = parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')) + const type = parsed.data.type || parsed.data.is_a || null + const stat = sort === 'mtime' ? await fs.stat(filePath) : null + return { path: relativePath, title, type, mtime: stat?.mtimeMs ?? 0 } + })) + + const filtered = typeFilter + ? notes.filter(n => n.type === typeFilter) + : notes + + if (sort === 'mtime') { + filtered.sort((a, b) => b.mtime - a.mtime) + } else { + filtered.sort((a, b) => a.title.localeCompare(b.title)) + } + + return filtered.map(({ mtime: _mtime, ...rest }) => rest) +} + +/** + * Get vault context: unique types and 20 most recent notes. + * @param {string} vaultPath + * @returns {Promise<{types: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>} + */ +export async function vaultContext(vaultPath) { + const files = await findMarkdownFiles(vaultPath) + const typesSet = new Set() + const notesWithMtime = [] + + for (const filePath of files) { + const raw = await fs.readFile(filePath, 'utf-8') + const parsed = matter(raw) + const type = parsed.data.type || parsed.data.is_a || null + if (type) typesSet.add(type) + const stat = await fs.stat(filePath) + notesWithMtime.push({ + path: path.relative(vaultPath, filePath), + title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')), + type, + mtime: stat.mtimeMs, + }) + } + + notesWithMtime.sort((a, b) => b.mtime - a.mtime) + const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest) + + return { types: [...typesSet].sort(), recentNotes, vaultPath } +} + // --- Helpers --- /** diff --git a/mcp-server/ws-bridge.js b/mcp-server/ws-bridge.js index 54fbdafd..9db7efc7 100644 --- a/mcp-server/ws-bridge.js +++ b/mcp-server/ws-bridge.js @@ -5,19 +5,46 @@ * Exposes vault operations over WebSocket so the Laputa app frontend * can invoke MCP tools in real-time without going through stdio. * - * Usage: - * VAULT_PATH=/path/to/vault WS_PORT=9710 node ws-bridge.js + * Port 9710: Tool bridge — Claude/AI clients call vault tools here. + * Port 9711: UI bridge — Frontend listens for UI action broadcasts. * - * Protocol: + * Usage: + * VAULT_PATH=/path/to/vault WS_PORT=9710 WS_UI_PORT=9711 node ws-bridge.js + * + * Protocol (tool bridge): * Client sends: { "id": "req-1", "tool": "search_notes", "args": { "query": "test" } } * Server sends: { "id": "req-1", "result": { ... } } * On error: { "id": "req-1", "error": "message" } + * + * Protocol (UI bridge): + * Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." } */ import { WebSocketServer } from 'ws' -import { readNote, createNote, searchNotes, appendToNote } from './vault.js' +import { + readNote, createNote, searchNotes, appendToNote, + editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext, +} from './vault.js' const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa' const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10) +const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10) + +/** @type {WebSocketServer | null} */ +let uiBridge = null + +function broadcastUiAction(action, payload) { + if (!uiBridge) return + const msg = JSON.stringify({ type: 'ui_action', action, ...payload }) + for (const client of uiBridge.clients) { + if (client.readyState === 1) client.send(msg) + } +} + +function buildFrontmatter(args) { + const fm = {} + if (args.is_a) fm.is_a = args.is_a + return fm +} const TOOL_HANDLERS = { open_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })), @@ -25,12 +52,15 @@ const TOOL_HANDLERS = { create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)), search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit), append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => ({ ok: true })), -} - -function buildFrontmatter(args) { - const fm = {} - if (args.is_a) fm.is_a = args.is_a - return fm + edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch), + delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => ({ ok: true })), + link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title), + list_notes: (args) => listNotes(VAULT_PATH, args.type_filter, args.sort), + vault_context: () => vaultContext(VAULT_PATH), + ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } }, + ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } }, + ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } }, + ui_set_filter: (args) => { broadcastUiAction('set_filter', { type: args.type }); return { ok: true } }, } async function handleMessage(data) { @@ -50,6 +80,17 @@ async function handleMessage(data) { } } +export function startUiBridge(port = WS_UI_PORT) { + uiBridge = new WebSocketServer({ port }) + + uiBridge.on('connection', () => { + console.error(`[ws-bridge] UI client connected on port ${port}`) + }) + + console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`) + return uiBridge +} + export function startBridge(port = WS_PORT) { const wss = new WebSocketServer({ port }) @@ -75,5 +116,6 @@ export function startBridge(port = WS_PORT) { // Run directly if invoked as main module const isMain = process.argv[1]?.endsWith('ws-bridge.js') if (isMain) { + startUiBridge() startBridge() } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d6753058..bf7f56d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ pub mod ai_chat; pub mod frontmatter; pub mod git; pub mod github; +pub mod mcp; pub mod menu; pub mod search; pub mod settings; @@ -9,6 +10,8 @@ pub mod vault; use std::borrow::Cow; use std::path::Path; +use std::process::Child; +use std::sync::Mutex; use ai_chat::{AiChatRequest, AiChatResponse}; use frontmatter::FrontmatterValue; @@ -266,6 +269,16 @@ async fn search_vault( .map_err(|e| format!("Search task failed: {}", e))? } +struct WsBridgeChild(Mutex>); + +#[tauri::command] +async fn register_mcp_tools(vault_path: String) -> Result { + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || mcp::register_mcp(&vault_path)) + .await + .map_err(|e| format!("Registration task failed: {e}"))? +} + fn log_startup_result(label: &str, result: Result) { match result { Ok(n) if n > 0 => log::info!("{}: {} files", label, n), @@ -291,6 +304,12 @@ fn run_startup_tasks() { "Migrated is_a to type on startup", vault::migrate_is_a_to_type(vp_str), ); + + // Register Laputa MCP server in Claude Code and Cursor configs + match mcp::register_mcp(vp_str) { + Ok(status) => log::info!("MCP registration: {status}"), + Err(e) => log::warn!("MCP registration failed: {e}"), + } } #[cfg(test)] @@ -333,6 +352,7 @@ mod tests { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(WsBridgeChild(Mutex::new(None))) .setup(|app| { if cfg!(debug_assertions) { app.handle().plugin( @@ -360,6 +380,22 @@ pub fn run() { run_startup_tasks(); + // Spawn the MCP WebSocket bridge for the default vault + { + use tauri::Manager; + let vault_path = dirs::home_dir() + .map(|h| h.join("Laputa")) + .unwrap_or_default(); + let vp_str = vault_path.to_string_lossy().to_string(); + match mcp::spawn_ws_bridge(&vp_str) { + Ok(child) => { + let state: tauri::State<'_, WsBridgeChild> = app.state(); + *state.0.lock().unwrap() = Some(child); + } + Err(e) => log::warn!("Failed to start ws-bridge: {}", e), + } + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -395,8 +431,20 @@ pub fn run() { search_vault, create_getting_started_vault, check_vault_exists, - get_default_vault_path + get_default_vault_path, + register_mcp_tools ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app_handle, event| { + use tauri::Manager; + if let tauri::RunEvent::Exit = event { + let state: tauri::State<'_, WsBridgeChild> = app_handle.state(); + let mut guard = state.0.lock().unwrap(); + if let Some(ref mut child) = *guard { + let _ = child.kill(); + log::info!("ws-bridge child process killed on exit"); + } + } + }); } diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs new file mode 100644 index 00000000..030d7734 --- /dev/null +++ b/src-tauri/src/mcp.rs @@ -0,0 +1,214 @@ +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; + +/// Find the `node` binary path at runtime. +fn find_node() -> Result { + let output = Command::new("which") + .arg("node") + .output() + .map_err(|e| format!("Failed to run `which node`: {e}"))?; + if !output.status.success() { + return Err("node not found in PATH".into()); + } + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(PathBuf::from(path)) +} + +/// Resolve the path to `mcp-server/ws-bridge.js`. +/// +/// In dev mode, uses `CARGO_MANIFEST_DIR` (set at compile time). +/// In release mode, navigates from the current executable. +fn mcp_server_dir() -> Result { + // Dev mode: CARGO_MANIFEST_DIR points to src-tauri/ + let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("mcp-server"); + if dev_path.join("ws-bridge.js").exists() { + return Ok(std::fs::canonicalize(&dev_path).unwrap_or(dev_path)); + } + + // Release mode: relative to the executable + let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?; + let release_path = exe + .parent() + .and_then(|p| p.parent()) + .map(|p| p.join("mcp-server")) + .ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?; + if release_path.join("ws-bridge.js").exists() { + return Ok(release_path); + } + + Err(format!( + "mcp-server not found at {} or {}", + dev_path.display(), + release_path.display() + )) +} + +/// Spawn the WebSocket bridge as a child process. +pub fn spawn_ws_bridge(vault_path: &str) -> Result { + let node = find_node()?; + let server_dir = mcp_server_dir()?; + let script = server_dir.join("ws-bridge.js"); + + let child = Command::new(node) + .arg(&script) + .env("VAULT_PATH", vault_path) + .env("WS_PORT", "9710") + .env("WS_UI_PORT", "9711") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?; + + log::info!("ws-bridge spawned (pid: {})", child.id()); + Ok(child) +} + +/// Register Laputa as an MCP server in Claude Code and Cursor config files. +pub fn register_mcp(vault_path: &str) -> Result { + let server_dir = mcp_server_dir()?; + let index_js = server_dir + .join("index.js") + .to_string_lossy() + .into_owned(); + + let entry = serde_json::json!({ + "command": "node", + "args": [index_js], + "env": { "VAULT_PATH": vault_path } + }); + + let configs = [ + dirs::home_dir().map(|h| h.join(".claude").join("mcp.json")), + dirs::home_dir().map(|h| h.join(".cursor").join("mcp.json")), + ]; + + let mut status = "registered"; + for config_path in configs.into_iter().flatten() { + match upsert_mcp_config(&config_path, &entry) { + Ok(was_update) => { + if was_update { + status = "updated"; + } + } + Err(e) => log::warn!("Failed to update {}: {}", config_path.display(), e), + } + } + + Ok(status.to_string()) +} + +/// Insert or update the "laputa" entry in an MCP config file. +fn upsert_mcp_config( + config_path: &Path, + entry: &serde_json::Value, +) -> Result { + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?; + } + + let mut config: serde_json::Value = if config_path.exists() { + let raw = std::fs::read_to_string(config_path) + .map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?; + serde_json::from_str(&raw) + .map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))? + } else { + serde_json::json!({}) + }; + + let servers = config + .as_object_mut() + .ok_or("Config is not a JSON object")? + .entry("mcpServers") + .or_insert_with(|| serde_json::json!({})); + + let was_update = servers.get("laputa").is_some(); + + servers + .as_object_mut() + .ok_or("mcpServers is not a JSON object")? + .insert("laputa".to_string(), entry.clone()); + + let json = serde_json::to_string_pretty(&config) + .map_err(|e| format!("Failed to serialize config: {e}"))?; + std::fs::write(config_path, json) + .map_err(|e| format!("Cannot write {}: {e}", config_path.display()))?; + + Ok(was_update) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn register_mcp_creates_config_files() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("mcp.json"); + let entry = serde_json::json!({ + "command": "node", + "args": ["/test/mcp-server/index.js"], + "env": { "VAULT_PATH": "/test/vault" } + }); + + // First call creates the file + let was_update = upsert_mcp_config(&config_path, &entry).unwrap(); + assert!(!was_update); + + let raw = std::fs::read_to_string(&config_path).unwrap(); + let config: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!( + config["mcpServers"]["laputa"]["args"][0], + "/test/mcp-server/index.js" + ); + assert_eq!( + config["mcpServers"]["laputa"]["env"]["VAULT_PATH"], + "/test/vault" + ); + + // Second call updates + let entry2 = serde_json::json!({ + "command": "node", + "args": ["/test/mcp-server/index.js"], + "env": { "VAULT_PATH": "/new/vault" } + }); + let was_update = upsert_mcp_config(&config_path, &entry2).unwrap(); + assert!(was_update); + + let raw = std::fs::read_to_string(&config_path).unwrap(); + let config: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!( + config["mcpServers"]["laputa"]["env"]["VAULT_PATH"], + "/new/vault" + ); + } + + #[test] + fn upsert_preserves_other_servers() { + let tmp = tempfile::tempdir().unwrap(); + let config_path = tmp.path().join("mcp.json"); + + // Seed with existing config + let existing = serde_json::json!({ + "mcpServers": { + "other-server": { "command": "other", "args": [] } + } + }); + std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap(); + + let entry = serde_json::json!({ + "command": "node", + "args": ["/test/index.js"], + "env": { "VAULT_PATH": "/vault" } + }); + upsert_mcp_config(&config_path, &entry).unwrap(); + + let raw = std::fs::read_to_string(&config_path).unwrap(); + let config: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert!(config["mcpServers"]["other-server"].is_object()); + assert!(config["mcpServers"]["laputa"].is_object()); + } +} diff --git a/src/App.tsx b/src/App.tsx index 9a31457e..2f1ccef3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { StatusBar } from './components/StatusBar' import { SettingsPanel } from './components/SettingsPanel' import { GitHubVaultModal } from './components/GitHubVaultModal' import { WelcomeScreen } from './components/WelcomeScreen' +import { useMcpRegistration } from './hooks/useMcpRegistration' import { useVaultLoader } from './hooks/useVaultLoader' import { useSettings } from './hooks/useSettings' import { useNoteActions } from './hooks/useNoteActions' @@ -123,6 +124,7 @@ function App() { const { settings, saveSettings } = useSettings() useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key]) + useMcpRegistration(resolvedPath, setToastMessage) const autoSync = useAutoSync({ vaultPath: resolvedPath, diff --git a/src/hooks/useMcpRegistration.ts b/src/hooks/useMcpRegistration.ts new file mode 100644 index 00000000..847a7649 --- /dev/null +++ b/src/hooks/useMcpRegistration.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { isTauri, mockInvoke } from '../mock-tauri' + +function tauriCall(command: string, args: Record): Promise { + return isTauri() ? invoke(command, args) : mockInvoke(command, args) +} + +/** + * Registers Laputa as an MCP server in Claude Code and Cursor config files. + * Fires once per vault path (skips duplicates). + */ +export function useMcpRegistration( + vaultPath: string, + onToast: (msg: string) => void, +) { + const registeredRef = useRef(null) + + useEffect(() => { + if (registeredRef.current === vaultPath) return + registeredRef.current = vaultPath + + tauriCall('register_mcp_tools', { vaultPath }) + .then((status) => { + if (status === 'registered') { + onToast('Laputa registered as MCP tool for Claude Code') + } + }) + .catch(() => { + // Silently ignore — not critical for app operation + }) + }, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- onToast is stable via ref +} diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 6e05bc6b..77b7d04b 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -234,6 +234,7 @@ export const mockHandlers: Record any> = { return args.path.includes('demo-vault-v2') }, create_getting_started_vault: () => '/Users/mock/Documents/Laputa', + register_mcp_tools: () => 'registered', } export function addMockEntry(_entry: VaultEntry, content: string): void {