fix: add MCP create note tool
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user