feat(mcp-server): vault operation tools
Add Node.js MCP server with 5 vault tools: - open_note / read_note: read markdown file content - create_note: create new note with frontmatter and title - search_notes: search by title or content substring - append_to_note: append text to existing note Uses @modelcontextprotocol/sdk with stdio transport. VAULT_PATH env var configures the vault directory. 10 passing tests covering all operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
153
mcp-server/index.js
Normal file
153
mcp-server/index.js
Normal file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Laputa MCP Server — provides vault operation tools for AI assistants.
|
||||
*
|
||||
* Usage:
|
||||
* 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)
|
||||
* - 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
|
||||
*/
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { readNote, createNote, searchNotes, appendToNote } from './vault.js'
|
||||
|
||||
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'open_note',
|
||||
description: 'Open and read a note from the vault by its relative path',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'read_note',
|
||||
description: 'Read the full content of a note',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
},
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_note',
|
||||
description: 'Create a new note in the vault with a title and optional frontmatter',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path for the new note (e.g. "note/my-idea.md")' },
|
||||
title: { type: 'string', description: 'Title of the note' },
|
||||
is_a: { type: 'string', description: 'Entity type (Project, Note, Experiment, etc.)' },
|
||||
},
|
||||
required: ['path', 'title'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_notes',
|
||||
description: 'Search notes in the vault by title or content',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query string' },
|
||||
limit: { type: 'number', description: 'Maximum number of results (default: 10)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'append_to_note',
|
||||
description: 'Append text to the end of an existing note',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative path to the note' },
|
||||
text: { type: 'string', description: 'Text to append' },
|
||||
},
|
||||
required: ['path', 'text'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
open_note: handleReadNote,
|
||||
read_note: handleReadNote,
|
||||
create_note: handleCreateNote,
|
||||
search_notes: handleSearchNotes,
|
||||
append_to_note: handleAppendToNote,
|
||||
}
|
||||
|
||||
async function handleReadNote(args) {
|
||||
const content = await readNote(VAULT_PATH, args.path)
|
||||
return { content: [{ type: 'text', text: content }] }
|
||||
}
|
||||
|
||||
async function handleCreateNote(args) {
|
||||
const frontmatter = {}
|
||||
if (args.is_a) frontmatter.is_a = args.is_a
|
||||
const absPath = await createNote(VAULT_PATH, args.path, args.title, frontmatter)
|
||||
return { content: [{ type: 'text', text: `Created note at ${absPath}` }] }
|
||||
}
|
||||
|
||||
async function handleSearchNotes(args) {
|
||||
const results = await searchNotes(VAULT_PATH, args.query, args.limit)
|
||||
const text = results.length === 0
|
||||
? 'No matching notes found.'
|
||||
: results.map(r => `**${r.title}** (${r.path})\n${r.snippet}`).join('\n\n')
|
||||
return { content: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
async function handleAppendToNote(args) {
|
||||
await appendToNote(VAULT_PATH, args.path, args.text)
|
||||
return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] }
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'laputa-mcp-server', version: '0.1.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: TOOLS,
|
||||
}))
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
const handler = TOOL_HANDLERS[name]
|
||||
if (!handler) {
|
||||
throw new Error(`Unknown tool: ${name}`)
|
||||
}
|
||||
try {
|
||||
return await handler(args)
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
console.error(`Laputa MCP server running (vault: ${VAULT_PATH})`)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
1138
mcp-server/package-lock.json
generated
Normal file
1138
mcp-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
mcp-server/package.json
Normal file
14
mcp-server/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "laputa-mcp-server",
|
||||
"version": "0.1.0",
|
||||
"description": "MCP server for Laputa vault operations",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "node --test test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||
}
|
||||
}
|
||||
115
mcp-server/test.js
Normal file
115
mcp-server/test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, before, after } from 'node:test'
|
||||
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'
|
||||
|
||||
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 })
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'project', 'test-project.md'), `---
|
||||
title: Test Project
|
||||
is_a: Project
|
||||
status: Active
|
||||
---
|
||||
|
||||
# Test Project
|
||||
|
||||
This is a test project for the MCP server.
|
||||
`)
|
||||
|
||||
await fs.writeFile(path.join(tmpDir, 'note', 'daily-log.md'), `---
|
||||
title: Daily Log
|
||||
is_a: Note
|
||||
---
|
||||
|
||||
# Daily Log
|
||||
|
||||
Today I worked on the MCP server implementation.
|
||||
`)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('findMarkdownFiles', () => {
|
||||
it('should find all .md files recursively', async () => {
|
||||
const files = await findMarkdownFiles(tmpDir)
|
||||
assert.equal(files.length, 2)
|
||||
assert.ok(files.some(f => f.endsWith('test-project.md')))
|
||||
assert.ok(files.some(f => f.endsWith('daily-log.md')))
|
||||
})
|
||||
})
|
||||
|
||||
describe('readNote', () => {
|
||||
it('should read a note by relative path', async () => {
|
||||
const content = await readNote(tmpDir, 'project/test-project.md')
|
||||
assert.ok(content.includes('Test Project'))
|
||||
assert.ok(content.includes('is_a: Project'))
|
||||
})
|
||||
|
||||
it('should throw for missing notes', async () => {
|
||||
await assert.rejects(
|
||||
() => readNote(tmpDir, 'nonexistent.md'),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNote', () => {
|
||||
it('should create a note with frontmatter', async () => {
|
||||
const absPath = await createNote(tmpDir, 'note/new-note.md', 'My New Note', { is_a: 'Note' })
|
||||
assert.ok(absPath.endsWith('new-note.md'))
|
||||
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('title: My New Note'))
|
||||
assert.ok(content.includes('is_a: Note'))
|
||||
assert.ok(content.includes('# My New Note'))
|
||||
})
|
||||
|
||||
it('should create parent directories', async () => {
|
||||
const absPath = await createNote(tmpDir, 'deep/nested/dir/note.md', 'Deep Note')
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('# Deep Note'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should find notes matching title', async () => {
|
||||
const results = await searchNotes(tmpDir, 'Test Project')
|
||||
assert.ok(results.length >= 1)
|
||||
assert.equal(results[0].title, 'Test Project')
|
||||
})
|
||||
|
||||
it('should find notes matching content', async () => {
|
||||
const results = await searchNotes(tmpDir, 'MCP server')
|
||||
assert.ok(results.length >= 1)
|
||||
})
|
||||
|
||||
it('should return empty for no matches', async () => {
|
||||
const results = await searchNotes(tmpDir, 'xyzzy-nonexistent-12345')
|
||||
assert.equal(results.length, 0)
|
||||
})
|
||||
|
||||
it('should respect limit', async () => {
|
||||
const results = await searchNotes(tmpDir, 'note', 1)
|
||||
assert.ok(results.length <= 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendToNote', () => {
|
||||
it('should append text to a note', async () => {
|
||||
await appendToNote(tmpDir, 'note/daily-log.md', '## Evening Update\nFinished testing.')
|
||||
const content = await readNote(tmpDir, 'note/daily-log.md')
|
||||
assert.ok(content.includes('## Evening Update'))
|
||||
assert.ok(content.includes('Finished testing.'))
|
||||
})
|
||||
})
|
||||
138
mcp-server/vault.js
Normal file
138
mcp-server/vault.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Vault operations — file I/O for Laputa markdown vault.
|
||||
*/
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Recursively find all .md files under a directory.
|
||||
* @param {string} dir
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function findMarkdownFiles(dir) {
|
||||
const results = []
|
||||
const items = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const item of items) {
|
||||
if (item.name.startsWith('.')) continue
|
||||
const full = path.join(dir, item.name)
|
||||
if (item.isDirectory()) {
|
||||
results.push(...await findMarkdownFiles(full))
|
||||
} else if (item.name.endsWith('.md')) {
|
||||
results.push(full)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a note's content by path (absolute or relative to vault).
|
||||
* @param {string} vaultPath
|
||||
* @param {string} notePath
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function readNote(vaultPath, notePath) {
|
||||
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
|
||||
return fs.readFile(absPath, 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new note with optional frontmatter.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} relativePath
|
||||
* @param {string} title
|
||||
* @param {Record<string, string>} [frontmatter]
|
||||
* @returns {Promise<string>} The absolute path of the created file.
|
||||
*/
|
||||
export async function createNote(vaultPath, relativePath, title, frontmatter = {}) {
|
||||
const absPath = path.join(vaultPath, relativePath)
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true })
|
||||
|
||||
const fmEntries = { title, ...frontmatter }
|
||||
const fmLines = Object.entries(fmEntries)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\n')
|
||||
|
||||
const content = `---\n${fmLines}\n---\n\n# ${title}\n\n`
|
||||
await fs.writeFile(absPath, content, 'utf-8')
|
||||
return absPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Search notes by title or content substring.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} query
|
||||
* @param {number} [limit=10]
|
||||
* @returns {Promise<Array<{path: string, title: string, snippet: string}>>}
|
||||
*/
|
||||
export async function searchNotes(vaultPath, query, limit = 10) {
|
||||
const files = await findMarkdownFiles(vaultPath)
|
||||
const q = query.toLowerCase()
|
||||
const results = []
|
||||
|
||||
for (const filePath of files) {
|
||||
if (results.length >= limit) break
|
||||
const content = await fs.readFile(filePath, 'utf-8')
|
||||
const filename = path.basename(filePath, '.md')
|
||||
|
||||
const titleMatch = extractTitle(content, filename)
|
||||
const matches = titleMatch.toLowerCase().includes(q) || content.toLowerCase().includes(q)
|
||||
|
||||
if (matches) {
|
||||
const snippet = extractSnippet(content, q)
|
||||
results.push({
|
||||
path: path.relative(vaultPath, filePath),
|
||||
title: titleMatch,
|
||||
snippet,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Append text to the end of a note.
|
||||
* @param {string} vaultPath
|
||||
* @param {string} notePath
|
||||
* @param {string} text
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function appendToNote(vaultPath, notePath, text) {
|
||||
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
|
||||
const current = await fs.readFile(absPath, 'utf-8')
|
||||
const separator = current.endsWith('\n') ? '\n' : '\n\n'
|
||||
await fs.writeFile(absPath, current + separator + text + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/**
|
||||
* Extract title from markdown content (first H1 or frontmatter title).
|
||||
* @param {string} content
|
||||
* @param {string} fallback
|
||||
* @returns {string}
|
||||
*/
|
||||
function extractTitle(content, fallback) {
|
||||
const h1Match = content.match(/^#\s+(.+)$/m)
|
||||
if (h1Match) return h1Match[1].trim()
|
||||
|
||||
const titleMatch = content.match(/^title:\s*(.+)$/m)
|
||||
if (titleMatch) return titleMatch[1].trim()
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a snippet around the query match.
|
||||
* @param {string} content
|
||||
* @param {string} query
|
||||
* @returns {string}
|
||||
*/
|
||||
function extractSnippet(content, query) {
|
||||
const body = content.replace(/^---[\s\S]*?---\n?/, '').trim()
|
||||
const idx = body.toLowerCase().indexOf(query)
|
||||
if (idx === -1) return body.slice(0, 120)
|
||||
const start = Math.max(0, idx - 40)
|
||||
const end = Math.min(body.length, idx + query.length + 80)
|
||||
return (start > 0 ? '...' : '') + body.slice(start, end) + (end < body.length ? '...' : '')
|
||||
}
|
||||
Reference in New Issue
Block a user