feat: publish HoloLake model-native living system source

This commit is contained in:
冰朔 2026-08-03 10:04:41 +08:00
commit c395dd3a99
2467 changed files with 615073 additions and 0 deletions

View file

@ -0,0 +1,23 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { vaultContext } from './vault.js'
export async function readAgentInstructions(vaultPath) {
const instructionsPath = path.join(vaultPath, 'AGENTS.md')
try {
return {
path: instructionsPath,
content: await readFile(instructionsPath, 'utf8'),
}
} catch (error) {
if (error?.code === 'ENOENT') return null
throw error
}
}
export async function vaultContextWithInstructions(vaultPath) {
return {
...(await vaultContext(vaultPath)),
agentInstructions: await readAgentInstructions(vaultPath),
}
}

View file

@ -0,0 +1,20 @@
{
"current_namespace": "com.tolaria.app",
"legacy_namespace": "com.laputa.app",
"namespace_read_order": ["current", "legacy"],
"files": {
"settings": "settings.json",
"vaults": "vaults.json",
"last_vault": "last-vault.txt",
"ai_workspace_sessions": "ai-workspace-sessions.json",
"window_state": "window-state.json",
"ai_provider_secrets": "ai-provider-secrets.json"
},
"read_order": [
"preferred config root/current namespace",
"preferred config root/legacy namespace",
"platform config root/current namespace when different",
"platform config root/legacy namespace when different"
],
"write_target": "preferred config root/current namespace"
}

View file

@ -0,0 +1,113 @@
import { randomUUID } from 'node:crypto'
import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { appConfigFilePath } from './vault-path.js'
const MAX_SESSION_ID_BYTES = 128
const MAX_EVENT_KIND_BYTES = 64
const MAX_SUMMARY_BYTES = 12_000
const MAX_DETAIL_COUNT = 64
const MAX_DETAIL_BYTES = 12_000
const MAX_CHECKPOINT_BYTES = 1_000_000
function runtimeBasePath() {
return appConfigFilePath('hldp-runtime')
}
function validatedSessionId(value) {
const sessionId = typeof value === 'string' ? value.trim() : ''
if (
!sessionId
|| Buffer.byteLength(sessionId) > MAX_SESSION_ID_BYTES
|| !/^[A-Za-z0-9._-]+$/.test(sessionId)
) {
throw new Error('HLDP session id must use only letters, numbers, dot, dash, or underscore.')
}
return sessionId
}
function validatedText(label, value, maximumBytes) {
const text = typeof value === 'string' ? value.trim() : ''
if (!text) throw new Error(`${label} must not be empty.`)
if (Buffer.byteLength(text) > maximumBytes) {
throw new Error(`${label} exceeds the ${maximumBytes}-byte limit.`)
}
return text
}
function journalPath(basePath, sessionId) {
return path.join(basePath, 'sessions', `${sessionId}.hdlp.jsonl`)
}
function checkpointPath(basePath, sessionId) {
return path.join(basePath, 'checkpoints', `${sessionId}.hdlp`)
}
export async function recordHldpHeartbeat(args = {}, { basePath = runtimeBasePath() } = {}) {
const sessionId = validatedSessionId(args.session_id)
const eventKind = validatedText('HLDP event kind', args.event_kind, MAX_EVENT_KIND_BYTES)
const summary = validatedText('HLDP summary', args.summary, MAX_SUMMARY_BYTES)
const details = Array.isArray(args.details) ? args.details : []
if (details.length > MAX_DETAIL_COUNT) {
throw new Error(`HLDP heartbeat accepts at most ${MAX_DETAIL_COUNT} detail entries.`)
}
const normalizedDetails = details.map(detail => (
validatedText('HLDP detail', detail, MAX_DETAIL_BYTES)
))
const timestamp = new Date().toISOString()
const receiptId = randomUUID()
const targetPath = journalPath(basePath, sessionId)
await mkdir(path.dirname(targetPath), { recursive: true })
await appendFile(targetPath, `${JSON.stringify({
schema: 'hldp-heartbeat/v0.1',
session_id: sessionId,
receipt_id: receiptId,
timestamp,
event_kind: eventKind,
summary,
details: normalizedDetails,
source: 'persona',
})}\n`, 'utf8')
return {
sessionId,
receiptId,
relativePath: path.relative(basePath, targetPath),
timestamp,
}
}
export async function readHldpHeartbeat(args = {}, { basePath = runtimeBasePath() } = {}) {
const sessionId = validatedSessionId(args.session_id)
const requestedLimit = Number.isInteger(args.limit) ? args.limit : 200
const limit = Math.min(Math.max(requestedLimit, 1), 2_000)
try {
const content = await readFile(journalPath(basePath, sessionId), 'utf8')
return content
.trim()
.split('\n')
.filter(Boolean)
.slice(-limit)
.map(line => JSON.parse(line))
} catch (error) {
if (error?.code === 'ENOENT') return []
throw error
}
}
export async function finalizeHldpCheckpoint(args = {}, { basePath = runtimeBasePath() } = {}) {
const sessionId = validatedSessionId(args.session_id)
const checkpoint = validatedText(
'HLDP checkpoint',
args.checkpoint,
MAX_CHECKPOINT_BYTES,
)
const targetPath = checkpointPath(basePath, sessionId)
await mkdir(path.dirname(targetPath), { recursive: true })
await writeFile(targetPath, `${checkpoint}\n`, { encoding: 'utf8', flag: 'wx' })
return {
sessionId,
receiptId: randomUUID(),
relativePath: path.relative(basePath, targetPath),
timestamp: new Date().toISOString(),
}
}

View file

@ -0,0 +1,63 @@
import { describe, it, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import {
finalizeHldpCheckpoint,
readHldpHeartbeat,
recordHldpHeartbeat,
} from './hldp-journal.js'
let basePath
beforeEach(async () => {
basePath = await mkdtemp(path.join(os.tmpdir(), 'hololake-hldp-mcp-'))
})
afterEach(async () => {
await rm(basePath, { recursive: true, force: true })
})
describe('HLDP MCP journal', () => {
it('appends structured events and reads them in order', async () => {
await recordHldpHeartbeat({
session_id: 'chat-42',
event_kind: 'decision',
summary: 'Keep working memory local.',
details: ['Publish only finalized checkpoints.'],
}, { basePath })
await recordHldpHeartbeat({
session_id: 'chat-42',
event_kind: 'correction',
summary: 'Do not treat Codex logs as persona memory.',
}, { basePath })
const events = await readHldpHeartbeat({ session_id: 'chat-42' }, { basePath })
assert.equal(events.length, 2)
assert.equal(events[0].event_kind, 'decision')
assert.equal(events[1].event_kind, 'correction')
assert.equal(events[0].source, 'persona')
})
it('writes one immutable finalized checkpoint', async () => {
const receipt = await finalizeHldpCheckpoint({
session_id: 'chat-42',
checkpoint: '# Continuity checkpoint',
}, { basePath })
assert.equal(receipt.relativePath, path.join('checkpoints', 'chat-42.hdlp'))
assert.equal(
await readFile(path.join(basePath, receipt.relativePath), 'utf8'),
'# Continuity checkpoint\n',
)
await assert.rejects(
() => finalizeHldpCheckpoint({
session_id: 'chat-42',
checkpoint: '# Replacement',
}, { basePath }),
error => error?.code === 'EEXIST',
)
})
})

View file

@ -0,0 +1,426 @@
#!/usr/bin/env node
/**
* Tolaria MCP Server lightweight vault tools for AI agents.
*
* These MCP tools provide Tolaria-specific capabilities alongside each
* app-managed agent's own Safe / Power User permission profile:
*
* - 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
*/
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 WebSocket from 'ws'
import { createMcpToolService } from './tool-service.js'
import {
finalizeHldpCheckpoint,
readHldpHeartbeat,
recordHldpHeartbeat,
} from './hldp-journal.js'
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const WS_UI_URL = `ws://localhost:${WS_UI_PORT}`
const LOCAL_READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({
readOnlyHint: true,
destructiveHint: false,
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).
let uiSocket = null
let reconnectTimer = null
let shutdownStarted = false
const RECONNECT_INTERVAL_MS = 3000
function connectUiBridge() {
if (shutdownStarted) return
try {
const ws = new WebSocket(WS_UI_URL)
uiSocket = ws
ws.on('open', () => {
if (shutdownStarted) {
closeUiSocket()
return
}
console.error(`[mcp] Connected to UI bridge at ${WS_UI_URL}`)
})
ws.on('close', () => {
if (uiSocket === ws) uiSocket = null
scheduleUiReconnect()
})
ws.on('error', () => {
// Silent — bridge may not be running yet, will retry
})
} catch {
scheduleUiReconnect()
}
}
function scheduleUiReconnect() {
if (shutdownStarted) return
clearUiReconnectTimer()
reconnectTimer = setTimeout(connectUiBridge, RECONNECT_INTERVAL_MS)
reconnectTimer.unref?.()
}
function clearUiReconnectTimer() {
if (!reconnectTimer) return
clearTimeout(reconnectTimer)
reconnectTimer = null
}
function closeUiSocket() {
const socket = uiSocket
uiSocket = null
if (!socket) return
socket.removeAllListeners()
socket.on('error', () => {})
if (socket.readyState === WebSocket.CONNECTING) {
socket.terminate?.()
return
}
try {
socket.close()
} catch {
// Ignore close races during process teardown.
}
socket.terminate?.()
}
function broadcastUiAction(action, payload) {
if (!uiSocket || uiSocket.readyState !== WebSocket.OPEN) return
uiSocket.send(JSON.stringify({ type: 'ui_action', action, ...payload }))
}
const toolService = createMcpToolService({ emitUiAction: broadcastUiAction })
const TOOLS = [
{
name: 'record_hldp_heartbeat',
description: 'Append one structured, installation-local HLDP working-memory event for the current HoloLake conversation. Do not use per token.',
annotations: LOCAL_CREATE_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Stable HoloLake conversation id from the system prompt.' },
event_kind: { type: 'string', description: 'Structured kind such as goal, correction, decision, constraint, receipt, unfinished, or conflict.' },
summary: { type: 'string', description: 'Concise confirmed state change.' },
details: { type: 'array', items: { type: 'string' }, description: 'Optional evidence, cause, or restore-path details.' },
},
required: ['session_id', 'event_kind', 'summary'],
},
},
{
name: 'read_hldp_heartbeat',
description: 'Read recent installation-local HLDP working-memory events for continuity recovery.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Stable HoloLake conversation id from the system prompt.' },
limit: { type: 'number', description: 'Maximum recent events to return (default 200, maximum 2000).' },
},
required: ['session_id'],
},
},
{
name: 'finalize_hldp_checkpoint',
description: 'Finalize one persona-authored HLDP continuity checkpoint locally. This does not publish or push it to Git.',
annotations: LOCAL_CREATE_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Stable HoloLake conversation id from the system prompt.' },
checkpoint: { type: 'string', description: 'Complete compact HLDP checkpoint.' },
},
required: ['session_id', 'checkpoint'],
},
},
{
name: 'search_notes',
description: 'Full-text search across vault notes by title or content. Returns matching paths, titles, and snippets.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query string' },
limit: { type: 'number', description: 'Maximum number of results (default: 10)' },
},
required: ['query'],
},
},
{
name: 'get_vault_context',
description: 'Get vault orientation for the active Tolaria vaults: entity types, AGENTS.md instructions, note count, folders, and recent notes.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
vaultPath: { type: 'string', description: 'Optional target vault root. Omit to inspect all active vaults.' },
},
},
},
{
name: 'list_vaults',
description: 'List the current active Tolaria vaults available to MCP tools, including whether each vault has AGENTS.md instructions.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'get_note',
description: 'Read a note with parsed YAML frontmatter and markdown content. Returns {path, frontmatter, content}.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note (e.g. "project/my-project.md")' },
vaultPath: { type: 'string', description: 'Optional target vault root when multiple vaults are active.' },
},
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.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
vaultPath: { type: 'string', description: 'Optional target vault root when opening a note outside the default vault.' },
},
required: ['path'],
},
},
{
name: 'highlight_editor',
description: 'Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'Which UI element to highlight' },
path: { type: 'string', description: 'Optional note path to associate with the highlight' },
},
required: ['element'],
},
},
{
name: 'refresh_vault',
description: 'Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.',
annotations: LOCAL_READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Optional specific note path that changed' },
vaultPath: { type: 'string', description: 'Optional target vault root when refreshing a note outside the default vault.' },
},
},
},
]
async function handleSearchNotes(args) {
const results = await toolService.searchNotes(args)
const text = results.length === 0
? 'No matching notes found.'
: results.map(r => `**${r.title}** (${r.vaultLabel} / ${r.path})\n${r.snippet}`).join('\n\n')
return { content: [{ type: 'text', text }] }
}
async function handleVaultContext(args = {}) {
const ctx = await toolService.vaultContext(args)
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
}
async function handleListVaults() {
return { content: [{ type: 'text', text: JSON.stringify(await toolService.listVaults(), null, 2) }] }
}
async function handleGetNote(args) {
const note = await toolService.readNote(args)
return { content: [{ type: 'text', text: JSON.stringify(note, null, 2) }] }
}
async function handleCreateNote(args = {}) {
const note = await toolService.createNote(args)
return {
content: [{
type: 'text',
text: JSON.stringify(note, null, 2),
}],
}
}
async function handleRecordHldpHeartbeat(args = {}) {
return {
content: [{
type: 'text',
text: JSON.stringify(await recordHldpHeartbeat(args), null, 2),
}],
}
}
async function handleReadHldpHeartbeat(args = {}) {
return {
content: [{
type: 'text',
text: JSON.stringify(await readHldpHeartbeat(args), null, 2),
}],
}
}
async function handleFinalizeHldpCheckpoint(args = {}) {
return {
content: [{
type: 'text',
text: JSON.stringify(await finalizeHldpCheckpoint(args), 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.
const { targetPath } = toolService.openNoteAsTab(args)
return { content: [{ type: 'text', text: `Opening ${targetPath} in Tolaria` }] }
}
function handleHighlightEditor(args) {
toolService.highlightEditor(args)
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
}
function handleRefreshVault(args) {
toolService.refreshVault(args)
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
}
const TOOL_HANDLERS = new Map([
['record_hldp_heartbeat', handleRecordHldpHeartbeat],
['read_hldp_heartbeat', handleReadHldpHeartbeat],
['finalize_hldp_checkpoint', handleFinalizeHldpCheckpoint],
['search_notes', handleSearchNotes],
['get_vault_context', handleVaultContext],
['list_vaults', handleListVaults],
['get_note', handleGetNote],
['create_note', handleCreateNote],
['open_note', handleOpenNote],
['highlight_editor', handleHighlightEditor],
['refresh_vault', handleRefreshVault],
])
function callToolHandler(name, args) {
const handler = TOOL_HANDLERS.get(name)
if (!handler) throw new Error(`Unknown tool: ${name}`)
return handler(args)
}
// --- Server setup ---
const server = new Server(
{ name: 'tolaria-mcp-server', version: '0.3.0' },
{ capabilities: { tools: {} } },
)
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOLS,
}))
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params
try {
return await callToolHandler(name, args)
} catch (error) {
return {
content: [{ type: 'text', text: `Error: ${error.message}` }],
isError: true,
}
}
})
async function shutdown(exitCode = 0) {
if (shutdownStarted) return
shutdownStarted = true
clearUiReconnectTimer()
closeUiSocket()
try {
await server.close()
} catch (error) {
console.error(`[mcp] Error while closing server: ${error.message}`)
}
process.exitCode = exitCode
setImmediate(() => process.exit(exitCode))
}
async function main() {
const transport = new StdioServerTransport()
server.onclose = () => {
void shutdown(0)
}
process.stdin.once('end', () => {
void shutdown(0)
})
process.stdin.once('close', () => {
void shutdown(0)
})
process.once('SIGINT', () => {
void shutdown(0)
})
process.once('SIGTERM', () => {
void shutdown(0)
})
connectUiBridge()
await server.connect(transport)
console.error('Tolaria MCP server running (vaults resolved per call)')
}
main().catch((error) => {
console.error(error)
void shutdown(1)
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
{
"name": "tolaria-mcp-server",
"version": "0.1.0",
"description": "MCP server for Tolaria vault operations",
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "node --test test.js tool-service.test.js hldp-journal.test.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"ws": "^8.20.1"
},
"overrides": {
"@hono/node-server": "1.19.13",
"express-rate-limit": "8.2.2",
"fast-uri": "3.1.2",
"hono": "4.12.25",
"qs": "6.15.2",
"ip-address": "10.1.1",
"path-to-regexp": "8.4.0"
}
}

View file

@ -0,0 +1,608 @@
import { describe, it, before, after } from 'node:test'
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import {
access, mkdtemp, mkdir, open, readFile, rm, writeFile,
} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { clearTimeout, setTimeout } from 'node:timers'
import { fileURLToPath } from 'node:url'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import {
createNote, findMarkdownFiles, getNote, searchNotes, vaultContext,
} from './vault.js'
import {
appConfigBaseDirs,
appConfigFilePath,
requireVaultPath,
requireVaultPaths,
} from './vault-path.js'
import { vaultContextWithInstructions } from './agent-instructions.js'
import { evaluateBridgeRequest } from './ws-bridge.js'
let tmpDir
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
const MCP_SERVER_DIR = path.dirname(fileURLToPath(import.meta.url))
before(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
await mkdir(path.join(tmpDir, 'project'), { recursive: true })
await mkdir(path.join(tmpDir, 'note'), { recursive: true })
await writeTextFile(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 writeTextFile(path.join(tmpDir, 'note', 'daily-log.md'), `---
title: Daily Log
is_a: Note
---
# Daily Log
Today I worked on the MCP server implementation.
`)
await writeTextFile(path.join(tmpDir, 'note', 'hashtag-tags.md'), `---
title: Hashtag Tags
type: Note
tags: [#abc, def, ghi]
---
# Hashtag Tags
This note has AI-generated hashtag-style YAML tags.
`)
await writeTextFile(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.
`)
})
after(async () => {
await 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, 4)
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')))
assert.ok(files.some(f => f.endsWith('hashtag-tags.md')))
})
})
describe('getNote', () => {
it('should read a note with parsed frontmatter', async () => {
const note = await getNote(tmpDir, 'project/test-project.md')
assert.equal(note.path, 'project/test-project.md')
assert.equal(note.frontmatter.title, 'Test Project')
assert.equal(note.frontmatter.is_a, 'Project')
assert.ok(note.content.includes('test project for the MCP server'))
})
it('should tolerate hashtag-style tags in malformed YAML frontmatter', async () => {
const note = await getNote(tmpDir, 'note/hashtag-tags.md')
assert.equal(note.path, 'note/hashtag-tags.md')
assert.equal(note.frontmatter.title, 'Hashtag Tags')
assert.equal(note.frontmatter.type, 'Note')
assert.deepEqual(note.frontmatter.tags, ['#abc', 'def', 'ghi'])
assert.ok(note.content.includes('has AI-generated hashtag-style YAML tags'))
})
it('should throw for missing notes', async () => {
await assert.rejects(
() => getNote(tmpDir, 'nonexistent.md'),
{ code: 'ENOENT' }
)
})
it('should reject absolute paths outside the vault', async () => {
await assertRejectsOutsideVault('laputa-mcp-outside-', outsideNote => outsideNote)
})
it('should reject traversal paths outside the vault', async () => {
await assertRejectsOutsideVault(
'laputa-mcp-traversal-',
outsideNote => path.relative(tmpDir, outsideNote),
)
})
})
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')
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, 'project', 1)
assert.ok(results.length <= 1)
})
})
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 include notes with hashtag-style tags in malformed YAML frontmatter', async () => {
const ctx = await vaultContext(tmpDir)
const note = ctx.recentNotes.find(entry => entry.path === 'note/hashtag-tags.md')
assert.ok(note)
assert.equal(note.title, 'Hashtag Tags')
assert.equal(note.type, '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)
}
})
it('should include folders', async () => {
const ctx = await vaultContext(tmpDir)
assert.ok(ctx.folders.includes('project/'))
assert.ok(ctx.folders.includes('note/'))
})
it('should report correct note count', async () => {
const ctx = await vaultContext(tmpDir)
assert.equal(ctx.noteCount, 4)
})
it('includes root AGENTS.md instructions when present', async () => {
const agentsPath = path.join(tmpDir, 'AGENTS.md')
await writeFile(agentsPath, '# Vault Rules\n\nUse this vault carefully.\n', 'utf-8')
try {
const ctx = await vaultContextWithInstructions(tmpDir)
assert.deepEqual(ctx.agentInstructions, {
path: agentsPath,
content: '# Vault Rules\n\nUse this vault carefully.\n',
})
} finally {
await rm(agentsPath, { force: true })
}
})
it('reports null agent instructions when AGENTS.md is absent', async () => {
const ctx = await vaultContextWithInstructions(tmpDir)
assert.equal(ctx.agentInstructions, null)
})
})
describe('evaluateBridgeRequest', () => {
it('accepts loopback UI requests from trusted origins', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'ui',
origin: 'http://localhost:5202',
remoteAddress: '127.0.0.1',
}),
{ ok: true, reason: null },
)
})
it('rejects browser origins on the tool bridge', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'tool',
origin: 'https://evil.example',
remoteAddress: '127.0.0.1',
}),
{ ok: false, reason: 'browser origins are not allowed on the tool bridge' },
)
})
it('rejects non-loopback clients even without an origin', () => {
assert.deepEqual(
evaluateBridgeRequest({
bridgeType: 'ui',
origin: undefined,
remoteAddress: '192.168.1.10',
}),
{ ok: false, reason: 'non-local client' },
)
})
})
describe('requireVaultPath', () => {
it('returns the explicit configured vault path', () => {
assert.equal(
requireVaultPath({ VAULT_PATH: '/tmp/Selected Vault' }),
'/tmp/Selected Vault',
)
})
it('rejects missing vault paths instead of falling back to ~/Laputa', async () => {
const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-empty-config-'))
assert.throws(
() => requireVaultPaths({}, { configDir }),
/VAULT_PATH is required/,
)
await rm(configDir, { recursive: true, force: true })
})
it('returns all configured active vault paths with the primary vault first', () => {
assert.deepEqual(
requireVaultPaths({
VAULT_PATH: '/tmp/Default Vault',
VAULT_PATHS: JSON.stringify(['/tmp/Default Vault', '/tmp/Second Vault']),
}),
['/tmp/Default Vault', '/tmp/Second Vault'],
)
})
it('loads active mounted vault paths from Tolaria config when env is vault-neutral', async () => {
const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-config-'))
const primaryVault = path.join(configDir, 'Primary Vault')
const secondaryVault = path.join(configDir, 'Secondary Vault')
const hiddenVault = path.join(configDir, 'Hidden Vault')
const configPath = path.join(configDir, 'com.tolaria.app', 'vaults.json')
await mkdir(path.dirname(configPath), { recursive: true })
await writeFile(configPath, JSON.stringify({
active_vault: primaryVault,
vaults: [
{ label: 'Secondary', path: secondaryVault, mounted: true },
{ label: 'Hidden', path: hiddenVault, mounted: false },
{ label: 'Primary', path: primaryVault, mounted: true },
],
}), 'utf-8')
try {
assert.deepEqual(
requireVaultPaths({}, { configDir }),
[primaryVault, secondaryVault],
)
} finally {
await rm(configDir, { recursive: true, force: true })
}
})
it('uses one app config resolver for settings and vault registry files', async () => {
const configDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-shared-config-'))
const files = ['settings.json', 'vaults.json']
for (const fileName of files) {
const legacyPath = path.join(configDir, 'com.laputa.app', fileName)
await mkdir(path.dirname(legacyPath), { recursive: true })
await writeFile(legacyPath, '{}', 'utf-8')
}
try {
for (const fileName of files) {
assert.equal(
appConfigFilePath(fileName, { configDir }),
path.join(configDir, 'com.laputa.app', fileName),
)
}
} finally {
await rm(configDir, { recursive: true, force: true })
}
})
it('loads macOS vault registry from the XDG-backed Tolaria config before Application Support', async () => {
const homeDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-macos-home-'))
const primaryVault = path.join(homeDir, 'Primary Vault')
const legacyPlatformVault = path.join(homeDir, 'Legacy Platform Vault')
const xdgConfigPath = path.join(homeDir, '.config', 'com.tolaria.app', 'vaults.json')
const platformConfigPath = path.join(
homeDir,
'Library',
'Application Support',
'com.tolaria.app',
'vaults.json',
)
await mkdir(path.dirname(xdgConfigPath), { recursive: true })
await mkdir(path.dirname(platformConfigPath), { recursive: true })
await writeFile(xdgConfigPath, JSON.stringify({ active_vault: primaryVault, vaults: [] }), 'utf-8')
await writeFile(
platformConfigPath,
JSON.stringify({ active_vault: legacyPlatformVault, vaults: [] }),
'utf-8',
)
try {
assert.deepEqual(
requireVaultPaths({}, {
configDirs: appConfigBaseDirs({ env: {}, homeDir, platformName: 'darwin' }),
}),
[primaryVault],
)
} finally {
await rm(homeDir, { recursive: true, force: true })
}
})
})
describe('stdio process lifecycle', () => {
it('advertises local vault tools as approval-safe for MCP clients', async () => {
const { client, stderr } = await connectMcpClient()
try {
const { tools } = await client.listTools()
const toolsByName = new Map(tools.map(tool => [tool.name, tool]))
const safeReadTools = [
'search_notes',
'get_vault_context',
'list_vaults',
'get_note',
'open_note',
'highlight_editor',
'refresh_vault',
]
for (const name of safeReadTools) {
const tool = toolsByName.get(name)
assert.ok(tool, `Missing MCP tool: ${name}`)
assert.equal(tool.annotations?.readOnlyHint, true, `${name} should not require destructive approval`)
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,
env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' },
stdio: ['pipe', 'ignore', 'pipe'],
})
let stderr = ''
child.stderr.setEncoding('utf8')
child.stderr.on('data', chunk => {
stderr += chunk
})
await sleep(200)
child.stdin.end()
const exit = await waitForExit(child, 1_500)
if (!exit) {
child.kill()
await waitForExit(child, 1_000)
assert.fail(`MCP server stayed alive after stdin closed.\n${stderr}`)
}
assert.equal(exit.signal, null)
assert.equal(exit.code, 0, stderr)
})
})
async function connectMcpClient() {
const transport = new StdioClientTransport({
command: process.execPath,
args: ['index.js'],
cwd: MCP_SERVER_DIR,
env: { ...process.env, VAULT_PATH: tmpDir, WS_UI_PORT: '65534' },
stderr: 'pipe',
})
const stderr = collectTransportStderr(transport)
const client = new Client(
{ name: 'tolaria-mcp-test-client', version: '0.0.0' },
{ capabilities: {} },
)
await client.connect(transport)
return { client, stderr }
}
function collectTransportStderr(transport) {
const chunks = []
transport.stderr?.setEncoding('utf8')
transport.stderr?.on('data', chunk => {
chunks.push(chunk)
})
return () => chunks.join('')
}
async function closeMcpClient(client, stderr) {
try {
await client.close()
} catch (error) {
assert.fail(`Failed to close MCP test client: ${error.message}\n${stderr()}`)
}
}
async function assertRejectsOutsideVault(prefix, resolveNotePath) {
const outsideDir = await mkdtemp(path.join(os.tmpdir(), prefix))
const outsideNote = path.join(outsideDir, 'outside.md')
try {
await writeTextFile(outsideNote, '# Outside\n')
await assert.rejects(
() => getNote(tmpDir, resolveNotePath(outsideNote)),
{ message: ACTIVE_VAULT_ERROR },
)
} finally {
await rm(outsideDir, { recursive: true, force: true })
}
}
async function writeTextFile(filePath, content) {
const handle = await open(filePath, 'w')
try {
await handle.writeFile(content, 'utf-8')
} finally {
await handle.close()
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function waitForExit(child, timeoutMs) {
return new Promise((resolve) => {
const timer = setTimeout(() => {
cleanup()
resolve(null)
}, timeoutMs)
child.once('exit', onExit)
function onExit(code, signal) {
cleanup()
resolve({ code, signal })
}
function cleanup() {
clearTimeout(timer)
child.off('exit', onExit)
}
})
}

View file

@ -0,0 +1,213 @@
import path from 'node:path'
import {
createNote as createVaultNote,
getNote,
searchNotes as searchVaultNotes,
} from './vault.js'
import { requireVaultPaths } from './vault-path.js'
import { readAgentInstructions, vaultContextWithInstructions } from './agent-instructions.js'
export function createMcpToolService({
resolveVaultPaths = () => requireVaultPaths(),
emitUiAction = () => {},
} = {}) {
function activeVaultPaths() {
return resolveVaultPaths()
}
function requestedVaultPath(args = {}) {
const requested = typeof args.vaultPath === 'string' ? args.vaultPath.trim() : ''
if (!requested) return null
if (!activeVaultPaths().includes(requested)) {
throw new Error(`Vault is not active in Tolaria: ${requested}`)
}
return requested
}
function resolveUiPath(args = {}) {
const notePath = typeof args.path === 'string' ? args.path : ''
if (path.isAbsolute(notePath)) return notePath
const roots = activeVaultPaths()
const vaultPath = requestedVaultPath(args) ?? (roots.length === 1 ? roots[0] : '')
return vaultPath ? path.join(vaultPath, notePath) : notePath
}
async function readNote(args = {}) {
return getNoteFromActiveVaults(notePathArg(args), requestedVaultPath(args))
}
async function searchNotes(args = {}) {
const requestedLimit = Number.isFinite(args.limit) && args.limit > 0 ? args.limit : 10
const results = []
for (const vaultPath of activeVaultPaths()) {
const vaultResults = await searchVaultNotes(vaultPath, args.query, requestedLimit)
results.push(...vaultResults.map((result) => withVaultMetadata(result, vaultPath)))
if (results.length >= requestedLimit) break
}
return results.slice(0, requestedLimit)
}
async function vaultContext(args = {}) {
const targetVaultPath = requestedVaultPath(args)
const roots = activeVaultPaths()
if (targetVaultPath) return vaultContextWithInstructions(targetVaultPath)
if (roots.length === 1) return vaultContextWithInstructions(roots[0])
return {
vaults: await Promise.all(roots.map(vaultContextWithInstructions)),
}
}
async function listVaults() {
const vaults = await Promise.all(activeVaultPaths().map(async (vaultPath) => {
const agentInstructions = await readAgentInstructions(vaultPath)
return {
path: vaultPath,
label: vaultLabel(vaultPath),
agentInstructionsPath: agentInstructions?.path ?? null,
hasAgentInstructions: agentInstructions !== null,
}
}))
return { vaults }
}
async function createNote(args = {}) {
const vaultPath = writableVaultPath(args)
const notePath = writableNotePath(args, vaultPath)
const note = await createVaultNote(vaultPath, notePath, createNoteContent(args))
const targetPath = resolveUiPath({ ...args, path: note.path, vaultPath })
emitUiAction('vault_changed', { path: targetPath })
emitUiAction('open_tab', { path: targetPath })
return { path: note.path, absolutePath: note.absolutePath, vaultPath }
}
function openNoteAsTab(args = {}) {
const targetPath = resolveUiPath(args)
emitUiAction('vault_changed', { path: targetPath })
emitUiAction('open_tab', { path: targetPath })
return { targetPath }
}
function openNoteInEditor(args = {}) {
const targetPath = resolveUiPath(args)
emitUiAction('vault_changed', { path: targetPath })
emitUiAction('open_note', { path: targetPath })
return { targetPath }
}
function highlightEditor(args = {}) {
emitUiAction('highlight', { element: args.element, path: args.path })
}
function setFilter(args = {}) {
emitUiAction('set_filter', { filterType: args.type })
}
function refreshVault(args = {}) {
emitUiAction('vault_changed', { path: resolveUiPath(args) })
}
async function getNoteFromActiveVaults(notePath, vaultPath = null) {
const candidates = vaultPath ? [vaultPath] : activeVaultPaths()
const matches = []
const errors = []
for (const candidate of candidates) {
try {
matches.push(withVaultMetadata(await getNote(candidate, notePath), candidate))
} catch (error) {
errors.push(error)
}
}
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
throw new Error(`Note path is ambiguous across active vaults. Pass vaultPath for ${notePath}.`)
}
throw errors[0] ?? new Error(`Note not found: ${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}.`)
}
return {
activeVaultPaths,
createNote,
highlightEditor,
listVaults,
openNoteAsTab,
openNoteInEditor,
readNote,
refreshVault,
requestedVaultPath,
resolveUiPath,
searchNotes,
setFilter,
vaultContext,
}
}
function writableNotePath(args, vaultPath) {
const notePath = notePathArg(args)
if (!path.isAbsolute(notePath) || !isInsideVaultRoot(vaultPath, notePath)) return notePath
return path.relative(vaultPath, notePath)
}
function withVaultMetadata(note, vaultPath) {
return {
...note,
vaultPath,
vaultLabel: vaultLabel(vaultPath),
}
}
function vaultLabel(vaultPath) {
return path.basename(vaultPath) || vaultPath
}
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 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)
}

View file

@ -0,0 +1,156 @@
import { describe, it, beforeEach, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { createMcpToolService } from './tool-service.js'
let tmpDir
let firstVault
let secondVault
beforeEach(async () => {
tmpDir = await mkdtemp(path.join(os.tmpdir(), 'tolaria-mcp-service-'))
firstVault = path.join(tmpDir, 'First Vault')
secondVault = path.join(tmpDir, 'Second Vault')
await seedVault(firstVault, {
'note/shared.md': noteFixture('Shared Note', 'Shared content from the first vault.'),
'note/alpha.md': noteFixture('Alpha Project', 'Project planning in the first vault.'),
})
await seedVault(secondVault, {
'AGENTS.md': '# Second Vault Rules\n',
'note/shared.md': noteFixture('Shared Note', 'Shared content from the second vault.'),
'note/beta.md': noteFixture('Beta Project', 'Project planning in the second vault.'),
})
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
describe('createMcpToolService', () => {
it('requires vaultPath when reading an ambiguous note path', async () => {
const service = makeService()
await assert.rejects(
() => service.readNote({ path: 'note/shared.md' }),
/Note path is ambiguous across active vaults/,
)
const note = await service.readNote({
path: 'note/shared.md',
vaultPath: secondVault,
})
assert.equal(note.vaultPath, secondVault)
assert.equal(note.vaultLabel, 'Second Vault')
assert.match(note.content, /second vault/)
})
it('creates notes with fallback markdown and emits refresh and tab actions', async () => {
const emittedActions = []
const service = makeService({ emittedActions })
const absolutePath = path.join(secondVault, 'note/created.md')
const note = await service.createNote({
path: absolutePath,
title: 'Created From MCP',
type: 'Project',
})
assert.equal(note.path, 'note/created.md')
assert.equal(note.vaultPath, secondVault)
assert.equal(path.basename(note.absolutePath), 'created.md')
assert.equal(
await readFile(note.absolutePath, 'utf-8'),
'---\ntype: "Project"\n---\n\n# Created From MCP\n',
)
assert.deepEqual(emittedActions, [
{ action: 'vault_changed', payload: { path: absolutePath } },
{ action: 'open_tab', payload: { path: absolutePath } },
])
})
it('searches active vaults with consistent vault metadata', async () => {
const service = makeService()
const results = await service.searchNotes({ query: 'Project', limit: 2 })
assert.equal(results.length, 2)
assert.deepEqual(
results.map(({ path: notePath, vaultPath, vaultLabel }) => ({
notePath,
vaultPath,
vaultLabel,
})),
[
{ notePath: 'note/alpha.md', vaultPath: firstVault, vaultLabel: 'First Vault' },
{ notePath: 'note/beta.md', vaultPath: secondVault, vaultLabel: 'Second Vault' },
],
)
})
it('lists active vaults with agent-instruction metadata', async () => {
const service = makeService()
assert.deepEqual(await service.listVaults(), {
vaults: [
{
path: firstVault,
label: 'First Vault',
agentInstructionsPath: null,
hasAgentInstructions: false,
},
{
path: secondVault,
label: 'Second Vault',
agentInstructionsPath: path.join(secondVault, 'AGENTS.md'),
hasAgentInstructions: true,
},
],
})
})
it('emits transport-neutral UI intents for note opening and filters', () => {
const emittedActions = []
const service = makeService({ emittedActions })
service.openNoteAsTab({ path: 'note/beta.md', vaultPath: secondVault })
service.openNoteInEditor({ path: 'note/beta.md', vaultPath: secondVault })
service.highlightEditor({ element: 'editor', path: 'note/beta.md' })
service.setFilter({ type: 'Project' })
service.refreshVault({ path: 'note/beta.md', vaultPath: secondVault })
assert.deepEqual(emittedActions, [
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'open_tab', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'open_note', payload: { path: path.join(secondVault, 'note/beta.md') } },
{ action: 'highlight', payload: { element: 'editor', path: 'note/beta.md' } },
{ action: 'set_filter', payload: { filterType: 'Project' } },
{ action: 'vault_changed', payload: { path: path.join(secondVault, 'note/beta.md') } },
])
})
})
function makeService({ emittedActions = [] } = {}) {
return createMcpToolService({
resolveVaultPaths: () => [firstVault, secondVault],
emitUiAction: (action, payload) => {
emittedActions.push({ action, payload })
},
})
}
async function seedVault(vaultPath, files) {
for (const [relativePath, content] of Object.entries(files)) {
const filePath = path.join(vaultPath, relativePath)
await mkdir(path.dirname(filePath), { recursive: true })
await writeFile(filePath, content, 'utf-8')
}
}
function noteFixture(title, body) {
return `---\ntitle: ${JSON.stringify(title)}\ntype: Note\n---\n\n# ${title}\n\n${body}\n`
}

View file

@ -0,0 +1,139 @@
import { existsSync, readFileSync } from 'node:fs'
import { homedir, platform } from 'node:os'
import { isAbsolute, join } from 'node:path'
import appConfigPolicy from './app-config-policy.json' with { type: 'json' }
const APP_CONFIG_DIR = appConfigPolicy.current_namespace
const APP_CONFIG_FILES = Object.freeze(appConfigPolicy.files)
function parseVaultPathList(rawValue) {
if (!rawValue?.trim()) return []
try {
const parsed = JSON.parse(rawValue)
if (Array.isArray(parsed)) return parsed.filter(value => typeof value === 'string')
} catch {
// Older clients only set VAULT_PATH; keep VAULT_PATHS strict JSON so paths
// with platform separators are never split incorrectly.
}
return []
}
function uniqueVaultPaths(paths) {
const seen = new Set()
const unique = []
for (const path of paths) {
const trimmed = path.trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
unique.push(trimmed)
}
return unique
}
function absolutePath(path) {
return typeof path === 'string' && isAbsolute(path) ? path : null
}
function defaultXdgConfigHome(platformName, homeDir) {
if (platformName === 'win32') return null
return absolutePath(homeDir) ? join(homeDir, '.config') : null
}
function platformConfigDir(env, platformName, homeDir) {
if (platformName === 'darwin') return join(homeDir, 'Library', 'Application Support')
if (platformName === 'win32') return absolutePath(env.APPDATA) || join(homeDir, 'AppData', 'Roaming')
return absolutePath(env.XDG_CONFIG_HOME) || defaultXdgConfigHome(platformName, homeDir)
}
export function appConfigBaseDirs({
env = process.env,
homeDir = homedir(),
platformName = platform(),
platformDir = platformConfigDir(env, platformName, homeDir),
} = {}) {
const primary = absolutePath(env.XDG_CONFIG_HOME)
|| defaultXdgConfigHome(platformName, homeDir)
|| platformDir
const dirs = primary ? [primary] : []
if (platformDir && platformDir !== primary) dirs.push(platformDir)
return dirs
}
function namespaceDir(namespace) {
if (namespace === 'current') return APP_CONFIG_DIR
if (namespace === 'legacy') return appConfigPolicy.legacy_namespace
throw new Error(`Unknown app config namespace: ${namespace}`)
}
function preferredAppConfigPath(configDir, fileName) {
return join(configDir, APP_CONFIG_DIR, fileName)
}
function existingOrPreferredAppConfigPath(configDirs, fileName) {
for (const configDir of configDirs) {
for (const namespace of appConfigPolicy.namespace_read_order) {
const candidate = join(configDir, namespaceDir(namespace), fileName)
if (existsSync(candidate)) return candidate
}
}
return preferredAppConfigPath(configDirs[0], fileName)
}
export function appConfigFilePath(
fileName,
{ configDir, configDirs = configDir ? [configDir] : appConfigBaseDirs() } = {},
) {
return existingOrPreferredAppConfigPath(configDirs, fileName)
}
export function vaultsJsonPath({
configDir,
configDirs = configDir ? [configDir] : appConfigBaseDirs(),
} = {}) {
return existingOrPreferredAppConfigPath(configDirs, APP_CONFIG_FILES.vaults)
}
function pushUniquePath(paths, value) {
const path = typeof value === 'string' ? value.trim() : ''
if (!path || paths.includes(path)) return
paths.push(path)
}
function activeVaultPathsFromList(list) {
const paths = []
pushUniquePath(paths, list?.active_vault)
for (const vault of list?.vaults ?? []) {
if (vault?.mounted === false) continue
pushUniquePath(paths, vault?.path)
}
return paths
}
export function configuredVaultPaths(options = {}) {
const filePath = vaultsJsonPath(options)
if (!existsSync(filePath)) return []
return activeVaultPathsFromList(JSON.parse(readFileSync(filePath, 'utf-8')))
}
export function requireVaultPaths(env = process.env, options = {}) {
const vaultPaths = uniqueVaultPaths([
env.VAULT_PATH?.trim() ?? '',
...parseVaultPathList(env.VAULT_PATHS),
])
if (vaultPaths.length === 0) {
const configuredPaths = configuredVaultPaths(options)
if (configuredPaths.length > 0) return configuredPaths
throw new Error('VAULT_PATH is required. Open a vault in Tolaria before starting MCP tools.')
}
return vaultPaths
}
export function requireVaultPath(env = process.env, options = {}) {
return requireVaultPaths(env, options)[0]
}

View file

@ -0,0 +1,460 @@
/**
* Vault operations read-only helpers for Tolaria markdown vault.
* 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 { mkdir, open, opendir, realpath } from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
const ACTIVE_VAULT_ERROR = 'Note path must stay inside the active vault'
/**
* Recursively find all .md files under a directory.
* @param {string} dir
* @returns {Promise<string[]>}
*/
export async function findMarkdownFiles(dir) {
const results = []
const items = await opendir(dir)
for await (const item of items) {
await collectMarkdownFile(results, dir, item)
}
return results
}
async function resolveVaultNotePath(vaultPath, notePath) {
const vaultRoot = await realpath(vaultPath)
const requestedPath = resolveRequestedNotePath(vaultRoot, notePath)
const noteRealPath = await realpath(requestedPath)
const relativePath = path.relative(vaultRoot, noteRealPath)
if (!isVaultRelativePath(relativePath)) {
throw new Error(ACTIVE_VAULT_ERROR)
}
return {
vaultRoot,
noteRealPath,
relativePath,
}
}
/**
* Read a note with parsed frontmatter and content.
* @param {string} vaultPath
* @param {string} notePath
* @returns {Promise<{path: string, frontmatter: Record<string, unknown>, content: string}>}
*/
export async function getNote(vaultPath, notePath) {
const {
noteRealPath,
relativePath,
} = await resolveVaultNotePath(vaultPath, notePath)
const raw = await readUtf8File(noteRealPath)
const parsed = parseMarkdownNote(raw)
return {
path: relativePath,
frontmatter: parsed.data,
content: parsed.content.trim(),
}
}
/**
* 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
* @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 readUtf8File(filePath)
const filename = path.basename(filePath, '.md')
const titleMatch = extractTitle(content, filename)
if (!matchesSearchQuery(titleMatch, content, q)) continue
const snippet = extractSnippet(content, q)
results.push({
path: path.relative(vaultPath, filePath),
title: titleMatch,
snippet,
})
}
return results
}
/**
* Get vault context: unique types, note count, top-level folders, and 20 most recent notes.
* @param {string} vaultPath
* @returns {Promise<{types: string[], noteCount: number, folders: 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 foldersSet = new Set()
const notesWithMtime = []
for (const filePath of files) {
const { topFolder, note, type } = await readVaultContextNote(vaultPath, filePath)
if (type) typesSet.add(type)
if (topFolder) foldersSet.add(topFolder)
notesWithMtime.push(note)
}
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
const recentNotes = notesWithMtime.slice(0, 20).map(contextNoteWithoutMtime)
return {
types: [...typesSet].sort(),
noteCount: files.length,
folders: [...foldersSet].sort(),
recentNotes,
configFiles: await readConfigFiles(vaultPath),
vaultPath,
}
}
// --- Helpers ---
async function collectMarkdownFile(results, dir, item) {
if (item.name.startsWith('.')) return
const full = resolveInside(dir, item.name)
if (!full) return
if (item.isDirectory()) {
results.push(...await findMarkdownFiles(full))
return
}
if (item.name.endsWith('.md')) {
results.push(full)
}
}
function resolveRequestedNotePath(vaultRoot, notePath) {
if (path.isAbsolute(notePath)) return notePath
const resolved = resolveInside(vaultRoot, notePath)
if (!resolved) throw new Error(ACTIVE_VAULT_ERROR)
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)
if (isVaultRelativePath(relative)) return resolved
return null
}
function isVaultRelativePath(relativePath) {
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
}
function matchesSearchQuery(title, content, query) {
return title.toLowerCase().includes(query) || content.toLowerCase().includes(query)
}
function contextNoteWithoutMtime(note) {
return {
path: note.path,
title: note.title,
type: note.type,
}
}
async function readVaultContextNote(vaultPath, filePath) {
const raw = await readUtf8File(filePath)
const parsed = parseMarkdownNote(raw)
const rel = path.relative(vaultPath, filePath)
const topFolder = extractTopFolder(rel)
const stat = await statFile(filePath)
const type = parsed.data.type || parsed.data.is_a || null
return {
topFolder,
type,
note: {
path: rel,
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
type,
mtime: stat.mtimeMs,
},
}
}
function parseMarkdownNote(raw) {
try {
const parsed = matter(raw)
const fallback = parseFrontmatterFallback(raw)
return shouldUseFallbackFrontmatter(parsed, fallback) ? fallback : parsed
} catch {
return parseFrontmatterFallback(raw)
}
}
function shouldUseFallbackFrontmatter(parsed, fallback) {
return Object.keys(parsed.data).length === 0 && Object.keys(fallback.data).length > 0
}
function parseFrontmatterFallback(raw) {
const split = splitFrontmatter(raw)
if (!split) return { data: {}, content: raw }
return {
data: parseFrontmatterBlock(split.frontmatter),
content: split.content,
}
}
function splitFrontmatter(raw) {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/)
if (!match) return null
return { frontmatter: match[1], content: match[2] }
}
function parseFrontmatterBlock(frontmatter) {
const data = {}
let listKey = null
for (const line of frontmatter.split(/\r?\n/)) {
const item = parseYamlListItem(line)
if (listKey && item !== null) {
data[listKey].push(parseYamlScalar(item))
continue
}
listKey = null
const field = parseTopLevelYamlField(line)
if (!field) continue
data[field.key] = field.value ? parseYamlValue(field.value) : []
listKey = field.value ? null : field.key
}
return data
}
function parseTopLevelYamlField(line) {
if (!line || line.trimStart() !== line || line.trimStart().startsWith('#')) return null
const separatorIndex = line.indexOf(':')
if (separatorIndex <= 0) return null
return {
key: stripMatchingQuotes(line.slice(0, separatorIndex).trim()),
value: line.slice(separatorIndex + 1).trim(),
}
}
function parseYamlValue(value) {
if (value.startsWith('[') && value.endsWith(']')) {
return splitInlineYamlArray(value).map(parseYamlScalar)
}
return parseYamlScalar(value)
}
function splitInlineYamlArray(value) {
const inner = value.slice(1, -1)
const items = []
let current = ''
let quote = null
for (const char of inner) {
if (quote) {
current += char
if (char === quote) quote = null
continue
}
if (char === '"' || char === "'") {
quote = char
current += char
continue
}
if (char === ',') {
items.push(current.trim())
current = ''
continue
}
current += char
}
if (current.trim()) items.push(current.trim())
return items
}
function parseYamlListItem(line) {
const match = line.match(/^\s+-\s*(.*)$/)
return match ? match[1].trim() : null
}
function parseYamlScalar(value) {
const unquoted = stripMatchingQuotes(value.trim())
if (unquoted !== value.trim()) return unquoted
if (/^(true|yes)$/i.test(unquoted)) return true
if (/^(false|no)$/i.test(unquoted)) return false
if (/^(null|~)$/i.test(unquoted)) return null
if (/^-?\d+(\.\d+)?$/.test(unquoted)) return Number(unquoted)
return unquoted
}
function stripMatchingQuotes(value) {
const first = value[0]
const last = value[value.length - 1]
return (first === '"' || first === "'") && first === last ? value.slice(1, -1) : value
}
function extractTopFolder(relativePath) {
const topFolder = relativePath.split(path.sep)[0]
return topFolder === relativePath ? null : `${topFolder}/`
}
async function readConfigFiles(vaultPath) {
const configFiles = {}
try {
const agentsPath = resolveInside(vaultPath, 'config/agents.md')
if (agentsPath) configFiles.agents = await readUtf8File(agentsPath)
} catch {
// config/agents.md may not exist yet
}
return configFiles
}
async function readUtf8File(filePath) {
const handle = await open(filePath, 'r')
try {
return await handle.readFile('utf-8')
} finally {
await handle.close()
}
}
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 {
return await handle.stat()
} finally {
await handle.close()
}
}
/**
* 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 ? '...' : '')
}

View file

@ -0,0 +1,241 @@
#!/usr/bin/env node
/**
* WebSocket bridge for Tolaria MCP tools.
*
* Exposes vault operations over WebSocket so the Tolaria app frontend
* can invoke MCP tools in real-time without going through stdio.
*
* Port 9710: Tool bridge Claude/AI clients call vault tools here.
* Port 9711: UI bridge Frontend listens for UI action broadcasts.
*
* 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 { createServer } from 'node:http'
import { WebSocketServer } from 'ws'
import { createMcpToolService } from './tool-service.js'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
const LOOPBACK_HOST = 'localhost'
const TRUSTED_UI_ORIGINS = new Set([
'tauri://localhost',
'http://tauri.localhost',
'https://tauri.localhost',
])
/** @type {WebSocketServer | null} */
let uiBridge = null
const UNKNOWN_TOOL = Symbol('unknown tool')
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)
}
}
const toolService = createMcpToolService({ emitUiAction: broadcastUiAction })
async function readNoteTool(args) {
const note = await toolService.readNote(args)
return { content: note.content, frontmatter: note.frontmatter }
}
function uiOpenNoteTool(args) {
toolService.openNoteInEditor(args)
return { ok: true }
}
function uiOpenTabTool(args) {
toolService.openNoteAsTab(args)
return { ok: true }
}
async function createNoteTool(args = {}) {
return { ok: true, ...(await toolService.createNote(args)) }
}
function highlightTool(args) {
toolService.highlightEditor(args)
return { ok: true }
}
function uiSetFilterTool(args) {
toolService.setFilter(args)
return { ok: true }
}
function refreshVaultTool(args) {
toolService.refreshVault(args)
return { ok: true }
}
const TOOL_EXECUTORS = [
['open_note', readNoteTool],
['read_note', readNoteTool],
['create_note', createNoteTool],
['search_notes', (args) => toolService.searchNotes(args)],
['vault_context', (args) => toolService.vaultContext(args)],
['list_vaults', () => toolService.listVaults()],
['ui_open_note', uiOpenNoteTool],
['ui_open_tab', uiOpenTabTool],
['ui_highlight', highlightTool],
['highlight_editor', highlightTool],
['ui_set_filter', uiSetFilterTool],
['refresh_vault', refreshVaultTool],
]
function callToolHandler(tool, args) {
const executor = TOOL_EXECUTORS.find(([name]) => name === tool)?.[1]
return executor ? executor(args) : UNKNOWN_TOOL
}
async function handleMessage(data) {
const msg = JSON.parse(data)
const { id, tool, args } = msg
try {
const result = await callToolHandler(tool, args || {})
if (result === UNKNOWN_TOOL) {
return { id, error: `Unknown tool: ${tool}` }
}
return { id, result }
} catch (err) {
return { id, error: err.message }
}
}
export function isLoopbackAddress(remoteAddress) {
return remoteAddress === '127.0.0.1'
|| remoteAddress === '::1'
|| remoteAddress === '::ffff:127.0.0.1'
}
export function isTrustedUiOrigin(origin) {
if (!origin) return true
if (TRUSTED_UI_ORIGINS.has(origin)) return true
return /^http:\/\/(?:localhost|127\.0\.0\.1):\d+$/u.test(origin)
}
export function evaluateBridgeRequest({ bridgeType, origin, remoteAddress }) {
if (!isLoopbackAddress(remoteAddress)) {
return { ok: false, reason: 'non-local client' }
}
if (bridgeType === 'tool' && origin) {
return { ok: false, reason: 'browser origins are not allowed on the tool bridge' }
}
if (bridgeType === 'ui' && !isTrustedUiOrigin(origin)) {
return { ok: false, reason: 'untrusted UI origin' }
}
return { ok: true, reason: null }
}
function verifyBridgeRequest(bridgeType) {
return (info, done) => {
const verdict = evaluateBridgeRequest({
bridgeType,
origin: info.origin,
remoteAddress: info.req.socket.remoteAddress,
})
if (!verdict.ok) {
console.error(`[ws-bridge] Rejected ${bridgeType} bridge client: ${verdict.reason}`)
done(false, 403, 'Forbidden')
return
}
done(true)
}
}
/**
* Attempt to start the UI bridge WebSocket server.
* Returns a Promise that resolves to the WebSocketServer or null if the port
* is unavailable (e.g. another Tolaria instance owns it).
*/
export function startUiBridge(port = WS_UI_PORT) {
return new Promise((resolve) => {
const httpServer = createServer()
httpServer.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`[ws-bridge] UI bridge port ${port} already in use, disabling bridge`)
} else {
console.error(`[ws-bridge] UI bridge error: ${err.message}`)
}
resolve(null)
})
httpServer.listen(port, LOOPBACK_HOST, () => {
const wss = new WebSocketServer({
server: httpServer,
verifyClient: verifyBridgeRequest('ui'),
})
wss.on('connection', (ws) => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
// Relay: when a client sends a message, broadcast to all OTHER clients.
// This allows the MCP stdio server (connected as a client) to reach the frontend.
ws.on('message', (raw) => {
for (const client of wss.clients) {
if (client !== ws && client.readyState === 1) client.send(raw.toString())
}
})
})
uiBridge = wss
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
resolve(wss)
})
})
}
export function startBridge(port = WS_PORT) {
const currentVaultPaths = toolService.activeVaultPaths()
const wss = new WebSocketServer({
port,
host: LOOPBACK_HOST,
verifyClient: verifyBridgeRequest('tool'),
})
wss.on('connection', (ws) => {
console.error(`[ws-bridge] Client connected (vaults: ${currentVaultPaths.join(', ')})`)
ws.on('message', async (raw) => {
try {
const response = await handleMessage(raw.toString())
ws.send(JSON.stringify(response))
} catch (err) {
ws.send(JSON.stringify({ error: `Parse error: ${err.message}` }))
}
})
ws.on('close', () => console.error('[ws-bridge] Client disconnected'))
})
console.error(`[ws-bridge] Listening on ws://${LOOPBACK_HOST}:${port}`)
return wss
}
// Run directly if invoked as main module
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
if (isMain) {
try {
toolService.activeVaultPaths()
startUiBridge().then(() => startBridge())
} catch (err) {
console.error(`[ws-bridge] ${err.message}`)
process.exit(1)
}
}