fix: add MCP create note tool

This commit is contained in:
lucaronin
2026-06-05 16:12:31 +02:00
parent 1309cf322e
commit 4e81ab8aa3
9 changed files with 406 additions and 13 deletions

View File

@@ -360,7 +360,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|------|--------|-------------|
| `open_note` | `path` | Open and read a note by relative path |
| `read_note` | `path` | Read note content (alias for `open_note`) |
| `create_note` | `path, title, [type]` | Create new note with title and optional type frontmatter |
| `create_note` | `path, content, [title], [type], [vaultPath]` | Create a new markdown note inside an active vault without overwriting existing files |
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
| `list_vaults` | — | List active mounted vaults and whether each has root `AGENTS.md` instructions |
| `append_to_note` | `path, text` | Append text to end of existing note |

View File

@@ -249,7 +249,7 @@ tolaria/
│ └── icons/ # App icons
├── mcp-server/ # MCP bridge (Node.js or Bun)
│ ├── index.js # MCP server entry (stdio, 14 tools)
│ ├── index.js # MCP server entry (stdio tools)
│ ├── vault.js # Vault file operations
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
│ ├── test.js # MCP server tests

View File

@@ -8,6 +8,7 @@
* - search_notes: full-text search across vault notes
* - get_vault_context: vault structure overview (types, note count, folders)
* - get_note: parsed frontmatter + content (convenience over raw cat)
* - create_note: create a new markdown note without overwriting existing files
* - open_note: signal Tolaria UI to open a note as a tab
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
* - refresh_vault: trigger vault rescan so new/modified files appear
@@ -19,7 +20,7 @@ import {
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import WebSocket from 'ws'
import { searchNotes, getNote } from './vault.js'
import { createNote, searchNotes, getNote } from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
import path from 'node:path'
@@ -32,6 +33,12 @@ const LOCAL_READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({
idempotentHint: true,
openWorldHint: false,
})
const LOCAL_CREATE_TOOL_ANNOTATIONS = Object.freeze({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
})
// Connect as a WebSocket CLIENT to the UI bridge (run by ws-bridge.js).
// The bridge relays messages to all other clients (the React frontend).
@@ -156,6 +163,23 @@ const TOOLS = [
required: ['path'],
},
},
{
name: 'create_note',
description: 'Create a new markdown note inside an active Tolaria vault. Does not overwrite existing files. Use content for the full markdown including YAML frontmatter and H1.',
annotations: LOCAL_CREATE_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path inside the vault, or an absolute path inside an active vault. Must end in .md.' },
content: { type: 'string', description: 'Full markdown note content, including YAML frontmatter when needed.' },
title: { type: 'string', description: 'Optional title used only when content is omitted.' },
type: { type: 'string', description: 'Optional note type used only when content is omitted.' },
is_a: { type: 'string', description: 'Legacy alias for type, used only when content is omitted.' },
vaultPath: { type: 'string', description: 'Optional target vault root when multiple vaults are active.' },
},
required: ['path'],
},
},
{
name: 'open_note',
description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.',
@@ -268,6 +292,51 @@ function uiPath(args = {}) {
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
function isInsideVaultRoot(vaultPath, notePath) {
const relative = path.relative(vaultPath, notePath)
return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative)
}
function notePathArg(args = {}) {
const notePath = typeof args.path === 'string' ? args.path.trim() : ''
if (!notePath) throw new Error('Note path is required')
return notePath
}
function writableVaultPath(args = {}) {
const requested = requestedVaultPath(args)
if (requested) return requested
const roots = activeVaultPaths()
const notePath = notePathArg(args)
if (path.isAbsolute(notePath)) {
const root = roots.find(vaultPath => isInsideVaultRoot(vaultPath, notePath))
if (root) return root
}
if (roots.length === 1) return roots[0]
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
}
function yamlScalar(value) {
return JSON.stringify(value)
}
function fallbackCreateNoteContent(args = {}) {
const title = typeof args.title === 'string' && args.title.trim() ? args.title.trim() : path.basename(notePathArg(args), '.md')
const type = typeof args.type === 'string' && args.type.trim()
? args.type.trim()
: typeof args.is_a === 'string' && args.is_a.trim()
? args.is_a.trim()
: 'Note'
return `---\ntype: ${yamlScalar(type)}\n---\n\n# ${title}\n`
}
function createNoteContent(args = {}) {
return typeof args.content === 'string' && args.content.trim()
? args.content
: fallbackCreateNoteContent(args)
}
async function handleSearchNotes(args) {
const results = await searchActiveVaults(args.query, args.limit)
const text = results.length === 0
@@ -300,6 +369,21 @@ async function handleGetNote(args) {
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
}
async function handleCreateNote(args = {}) {
const notePath = notePathArg(args)
const vaultPath = writableVaultPath(args)
const note = await createNote(vaultPath, notePath, createNoteContent(args))
const targetPath = uiPath({ ...args, path: note.path, vaultPath })
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_tab', { path: targetPath })
return {
content: [{
type: 'text',
text: JSON.stringify({ path: note.path, absolutePath: note.absolutePath, vaultPath }, null, 2),
}],
}
}
function handleOpenNote(args) {
// Refresh vault first so the new/modified note appears in the note list,
// then signal the UI to open it in a tab.
@@ -324,6 +408,7 @@ const TOOL_HANDLERS = new Map([
['get_vault_context', handleVaultContext],
['list_vaults', handleListVaults],
['get_note', handleGetNote],
['create_note', handleCreateNote],
['open_note', handleOpenNote],
['highlight_editor', handleHighlightEditor],
['refresh_vault', handleRefreshVault],

View File

@@ -2,7 +2,7 @@ import { describe, it, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import {
mkdtemp, mkdir, open, rm, writeFile,
access, mkdtemp, mkdir, open, readFile, rm, writeFile,
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import {
findMarkdownFiles, getNote, searchNotes, vaultContext,
createNote, findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
import { requireVaultPath, requireVaultPaths } from './vault-path.js'
import { vaultContextWithInstructions } from './agent-instructions.js'
@@ -126,6 +126,74 @@ describe('getNote', () => {
})
})
describe('createNote', () => {
it('creates a new markdown note inside the vault', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-'))
const content = `---
type: Note
---
# MCP Created
`
try {
const note = await createNote(vaultDir, 'note/mcp-created.md', content)
assert.equal(note.path, 'note/mcp-created.md')
assert.equal(await readFile(path.join(vaultDir, note.path), 'utf-8'), content)
} finally {
await rm(vaultDir, { recursive: true, force: true })
}
})
it('does not overwrite an existing note', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-existing-'))
const notePath = path.join(vaultDir, 'existing.md')
await writeFile(notePath, '# Existing\n', 'utf-8')
try {
await assert.rejects(
() => createNote(vaultDir, 'existing.md', '# Replacement\n'),
{ code: 'EEXIST' },
)
assert.equal(await readFile(notePath, 'utf-8'), '# Existing\n')
} finally {
await rm(vaultDir, { recursive: true, force: true })
}
})
it('rejects absolute paths outside the vault', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-'))
const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-'))
try {
await assert.rejects(
() => createNote(vaultDir, path.join(outsideDir, 'outside.md'), '# Outside\n'),
{ message: ACTIVE_VAULT_ERROR },
)
} finally {
await rm(vaultDir, { recursive: true, force: true })
await rm(outsideDir, { recursive: true, force: true })
}
})
it('rejects outside paths before creating missing parent folders', async () => {
const vaultDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-vault-'))
const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-create-outside-'))
const outsideParent = path.join(outsideDir, 'missing-parent')
try {
await assert.rejects(
() => createNote(vaultDir, path.join(outsideParent, 'outside.md'), '# Outside\n'),
{ message: ACTIVE_VAULT_ERROR },
)
await assert.rejects(() => access(outsideParent), { code: 'ENOENT' })
} finally {
await rm(vaultDir, { recursive: true, force: true })
await rm(outsideDir, { recursive: true, force: true })
}
})
})
describe('searchNotes', () => {
it('should find notes matching title', async () => {
const results = await searchNotes(tmpDir, 'Test Project')
@@ -330,11 +398,43 @@ describe('stdio process lifecycle', () => {
assert.equal(tool.annotations?.destructiveHint, false, `${name} should not be treated as destructive`)
assert.equal(tool.annotations?.openWorldHint, false, `${name} should stay scoped to local active vaults`)
}
const createTool = toolsByName.get('create_note')
assert.ok(createTool, 'Missing MCP tool: create_note')
assert.equal(createTool.annotations?.readOnlyHint, false)
assert.equal(createTool.annotations?.destructiveHint, false)
assert.equal(createTool.annotations?.openWorldHint, false)
} finally {
await closeMcpClient(client, stderr)
}
})
it('creates a note through the MCP create_note tool', async () => {
const { client, stderr } = await connectMcpClient()
const relativePath = 'note/mcp-tool-created.md'
const absolutePath = path.join(tmpDir, relativePath)
const content = `---
type: Note
---
# MCP Tool Created
`
try {
await rm(absolutePath, { force: true })
const result = await client.callTool({
name: 'create_note',
arguments: { path: relativePath, content },
})
assert.equal(await readFile(absolutePath, 'utf-8'), content)
assert.match(JSON.stringify(result.content), /mcp-tool-created\.md/)
} finally {
await rm(absolutePath, { force: true })
await closeMcpClient(client, stderr)
}
})
it('exits when the MCP client closes stdin', async () => {
const child = spawn(process.execPath, ['index.js'], {
cwd: MCP_SERVER_DIR,

View File

@@ -1,9 +1,10 @@
/**
* Vault operations — read-only helpers for Tolaria markdown vault.
* Write operations are handled by the app-managed agent's active permission
* profile and native file-edit tools when available.
* Most write operations are handled by the app-managed agent's active
* permission profile and native file-edit tools; createNote is intentionally
* narrow so read-only agents can create a new Markdown file without overwrite.
*/
import { open, opendir, realpath } from 'node:fs/promises'
import { mkdir, open, opendir, realpath } from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
@@ -60,6 +61,22 @@ export async function getNote(vaultPath, notePath) {
}
}
/**
* Create a new markdown note inside the vault without overwriting an existing file.
* @param {string} vaultPath
* @param {string} notePath
* @param {string} content
* @returns {Promise<{path: string, absolutePath: string}>}
*/
export async function createNote(vaultPath, notePath, content) {
const { requestedPath, relativePath } = await resolveNewVaultNotePath(vaultPath, notePath)
await writeNewUtf8File(requestedPath, content)
return {
path: relativePath,
absolutePath: requestedPath,
}
}
/**
* Search notes by title or content substring.
* @param {string} vaultPath
@@ -145,6 +162,61 @@ function resolveRequestedNotePath(vaultRoot, notePath) {
return resolved
}
async function resolveNewVaultNotePath(vaultPath, notePath) {
const requestedNotePath = validateNewNotePath(notePath)
const vaultRoot = await realpath(vaultPath)
const requestedPath = resolveRequestedNotePath(vaultRoot, requestedNotePath)
const relativePath = relativeNotePathInsideVault(vaultRoot, requestedPath)
await ensureWritableParentInsideVault(vaultRoot, requestedPath)
return { requestedPath, relativePath }
}
function validateNewNotePath(notePath) {
const trimmedPath = typeof notePath === 'string' ? notePath.trim() : ''
if (!trimmedPath) {
throw new Error('Note path is required')
}
if (!trimmedPath.endsWith('.md')) {
throw new Error('New notes must be markdown files ending in .md')
}
return trimmedPath
}
async function ensureWritableParentInsideVault(vaultRoot, requestedPath) {
const parentPath = path.dirname(requestedPath)
const existingAncestor = await nearestExistingAncestor(parentPath)
assertInsideVault(vaultRoot, existingAncestor)
await mkdir(parentPath, { recursive: true })
assertInsideVault(vaultRoot, await realpath(parentPath))
}
async function nearestExistingAncestor(targetPath) {
let currentPath = targetPath
while (currentPath && currentPath !== path.dirname(currentPath)) {
try {
return await realpath(currentPath)
} catch (error) {
if (error?.code !== 'ENOENT') throw error
currentPath = path.dirname(currentPath)
}
}
return realpath(currentPath)
}
function assertInsideVault(vaultRoot, targetPath) {
if (!isVaultRelativePath(path.relative(vaultRoot, targetPath))) {
throw new Error(ACTIVE_VAULT_ERROR)
}
}
function relativeNotePathInsideVault(vaultRoot, requestedPath) {
const relativePath = path.relative(vaultRoot, requestedPath)
if (!isVaultRelativePath(relativePath) || !relativePath) {
throw new Error(ACTIVE_VAULT_ERROR)
}
return relativePath
}
function resolveInside(root, target) {
const resolved = path.resolve(root, target)
const relative = path.relative(root, resolved)
@@ -338,6 +410,15 @@ async function readUtf8File(filePath) {
}
}
async function writeNewUtf8File(filePath, content) {
const handle = await open(filePath, 'wx')
try {
await handle.writeFile(content, 'utf-8')
} finally {
await handle.close()
}
}
async function statFile(filePath) {
const handle = await open(filePath, 'r')
try {

View File

@@ -22,7 +22,7 @@
import { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import {
getNote, searchNotes,
createNote, getNote, searchNotes,
} from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
@@ -62,6 +62,51 @@ function uiPath(args = {}) {
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
function isInsideVaultRoot(vaultPath, notePath) {
const relative = path.relative(vaultPath, notePath)
return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative)
}
function notePathArg(args = {}) {
const notePath = typeof args.path === 'string' ? args.path.trim() : ''
if (!notePath) throw new Error('Note path is required')
return notePath
}
function writableVaultPath(args = {}) {
const requested = requestedVaultPath(args)
if (requested) return requested
const roots = activeVaultPaths()
const notePath = notePathArg(args)
if (path.isAbsolute(notePath)) {
const root = roots.find(vaultPath => isInsideVaultRoot(vaultPath, notePath))
if (root) return root
}
if (roots.length === 1) return roots[0]
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
}
function yamlScalar(value) {
return JSON.stringify(value)
}
function fallbackCreateNoteContent(args = {}) {
const title = typeof args.title === 'string' && args.title.trim() ? args.title.trim() : path.basename(notePathArg(args), '.md')
const type = typeof args.type === 'string' && args.type.trim()
? args.type.trim()
: typeof args.is_a === 'string' && args.is_a.trim()
? args.is_a.trim()
: 'Note'
return `---\ntype: ${yamlScalar(type)}\n---\n\n# ${title}\n`
}
function createNoteContent(args = {}) {
return typeof args.content === 'string' && args.content.trim()
? args.content
: fallbackCreateNoteContent(args)
}
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
const matches = []
@@ -129,6 +174,16 @@ function uiOpenTabTool(args) {
return { ok: true }
}
async function createNoteTool(args = {}) {
const notePath = notePathArg(args)
const vaultPath = writableVaultPath(args)
const note = await createNote(vaultPath, notePath, createNoteContent(args))
const targetPath = uiPath({ ...args, path: note.path, vaultPath })
broadcastUiAction('vault_changed', { path: targetPath })
broadcastUiAction('open_tab', { path: targetPath })
return { ok: true, path: note.path, absolutePath: note.absolutePath, vaultPath }
}
function highlightTool(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
return { ok: true }
@@ -161,6 +216,7 @@ async function listVaultsTool() {
const TOOL_EXECUTORS = [
['open_note', readNoteTool],
['read_note', readNoteTool],
['create_note', createNoteTool],
['search_notes', (args) => searchActiveVaults(args.query, args.limit)],
['vault_context', () => activeVaultContext()],
['list_vaults', () => listVaultsTool()],

View File

@@ -23,6 +23,25 @@ describe('detectFileOperation', () => {
expect(cb.onFileModified).not.toHaveBeenCalled()
})
it('calls onFileCreated for create_note tool with relative markdown path', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'create_note', input: JSON.stringify({ path: 'note/generated.md' }), vaultPath: VAULT, callbacks: cb })
expect(cb.onFileCreated).toHaveBeenCalledWith('note/generated.md')
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('calls onFileCreated for create_note tool with Windows absolute path', () => {
const cb = makeCallbacks()
detectFileOperation({
toolName: 'create_note',
input: JSON.stringify({ path: String.raw`D:\Notes\Notas\nota-longa-teste-gerada-2.md` }),
vaultPath: String.raw`D:\Notes\Notas`,
callbacks: cb,
})
expect(cb.onFileCreated).toHaveBeenCalledWith('nota-longa-teste-gerada-2.md')
expect(cb.onVaultChanged).not.toHaveBeenCalled()
})
it('calls onFileModified for Edit tool with .md in vault', () => {
const cb = makeCallbacks()
detectFileOperation({ toolName: 'Edit', input: JSON.stringify({ file_path: `${VAULT}/note/test.md` }), vaultPath: VAULT, callbacks: cb })

View File

@@ -1,3 +1,5 @@
import { normalizeNotePathSeparators, normalizeVaultRelativePath } from '../utils/notePathIdentity'
export interface AgentFileCallbacks {
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
@@ -38,6 +40,11 @@ interface VaultRelativePathRequest {
vaultPath: string
}
interface NormalizedToolPath {
value: string
windowsStyle: boolean
}
export function detectFileOperation(operation: AgentFileOperation): void {
if (!operation.callbacks) return
const context = {
@@ -53,6 +60,9 @@ export function detectFileOperation(operation: AgentFileOperation): void {
case 'Write':
notifyWriteOperation(context)
return
case 'create_note':
notifyCreateNoteOperation(context)
return
case 'Edit':
notifyEditOperation(context)
}
@@ -72,6 +82,13 @@ function notifyWriteOperation(context: OperationContext): void {
})
}
function notifyCreateNoteOperation(context: OperationContext): void {
notifyCreatedPath({
relativePath: markdownCreationPathFromToolInput(context),
callbacks: context.callbacks,
})
}
function notifyEditOperation(context: OperationContext): void {
notifyModifiedPath({
relativePath: markdownPathFromToolInput(context),
@@ -102,6 +119,15 @@ function markdownPathFromToolInput(context: ToolInputContext): string | null {
})
}
function markdownCreationPathFromToolInput(context: ToolInputContext): string | null {
const filePath = parseFilePath(context)
if (!filePath?.endsWith('.md')) return null
const notePath = normalizeToolPath(filePath)
return isRelativePath(notePath)
? safeRelativeMarkdownPath(notePath)
: markdownVaultRelativePath({ filePath, vaultPath: context.vaultPath })
}
function parseFilePath(source: ToolInputSource): string | null {
const parsed = parseToolInput(source)
if (!parsed) return null
@@ -142,10 +168,35 @@ function markdownVaultRelativePath(request: {
}
function toVaultRelative({ filePath, vaultPath }: VaultRelativePathRequest): string | null {
const vaultRoot = vaultPath.replace(/\/+$/, '')
const prefix = `${vaultRoot}/`
if (!filePath.startsWith(prefix)) return null
return filePath.slice(prefix.length) || null
const vaultRoot = normalizeToolPath(vaultPath)
const notePath = normalizeToolPath(filePath)
return childPathInsideVault(vaultRoot, notePath)
}
function childPathInsideVault(vaultRoot: NormalizedToolPath, notePath: NormalizedToolPath): string | null {
const prefix = `${vaultRoot.value}/`
const caseInsensitive = vaultRoot.windowsStyle || notePath.windowsStyle
const normalizedPrefix = caseInsensitive ? prefix.toLowerCase() : prefix
const normalizedNotePath = caseInsensitive ? notePath.value.toLowerCase() : notePath.value
if (!normalizedNotePath.startsWith(normalizedPrefix)) return null
return notePath.value.slice(prefix.length) || null
}
function normalizeToolPath(value: string): NormalizedToolPath {
return {
value: normalizeNotePathSeparators(value).replace(/\/+$/u, ''),
windowsStyle: /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith('\\\\'),
}
}
function isRelativePath(path: NormalizedToolPath): boolean {
return !path.value.startsWith('/') && !path.windowsStyle
}
function safeRelativeMarkdownPath(path: NormalizedToolPath): string | null {
const relativePath = normalizeVaultRelativePath(path.value)
if (!relativePath || relativePath.startsWith('../') || relativePath.includes('/../')) return null
return relativePath
}
export function parseBashFileCreation(request: BashFileCreationRequest): string | null {

View File

@@ -79,6 +79,7 @@ const AGENT_SYSTEM_PREAMBLE = `You are working inside Tolaria, a local-first Mar
Notes are Markdown files with YAML frontmatter. Organization is primarily expressed through H1 titles, types, properties, wikilinks, and relationships, not folder structure.
Prefer file edit tools for note changes.
Use the provided MCP tools for: full-text search (search_notes), vault orientation (get_vault_context), parsed note reading (get_note), and opening notes in the UI (open_note).
Use create_note(path, content, vaultPath?) for new Markdown notes when shell writes are unavailable.
When you create or edit a note, call open_note(path) so the user sees it in Tolaria.
When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.