feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
113
product-source/hololake-platform/mcp-server/hldp-journal.js
Normal file
113
product-source/hololake-platform/mcp-server/hldp-journal.js
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
426
product-source/hololake-platform/mcp-server/index.js
Normal file
426
product-source/hololake-platform/mcp-server/index.js
Normal 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)
|
||||
})
|
||||
1269
product-source/hololake-platform/mcp-server/package-lock.json
generated
Normal file
1269
product-source/hololake-platform/mcp-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
product-source/hololake-platform/mcp-server/package.json
Normal file
25
product-source/hololake-platform/mcp-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
608
product-source/hololake-platform/mcp-server/test.js
Normal file
608
product-source/hololake-platform/mcp-server/test.js
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
213
product-source/hololake-platform/mcp-server/tool-service.js
Normal file
213
product-source/hololake-platform/mcp-server/tool-service.js
Normal 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)
|
||||
}
|
||||
156
product-source/hololake-platform/mcp-server/tool-service.test.js
Normal file
156
product-source/hololake-platform/mcp-server/tool-service.test.js
Normal 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`
|
||||
}
|
||||
139
product-source/hololake-platform/mcp-server/vault-path.js
Normal file
139
product-source/hololake-platform/mcp-server/vault-path.js
Normal 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]
|
||||
}
|
||||
460
product-source/hololake-platform/mcp-server/vault.js
Normal file
460
product-source/hololake-platform/mcp-server/vault.js
Normal 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 ? '...' : '')
|
||||
}
|
||||
241
product-source/hololake-platform/mcp-server/ws-bridge.js
Normal file
241
product-source/hololake-platform/mcp-server/ws-bridge.js
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue