diff --git a/mcp-server/index.js b/mcp-server/index.js index b4da1f11..d8c989b0 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -26,6 +26,12 @@ import path from 'node:path' 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, +}) // 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). @@ -107,6 +113,7 @@ const TOOLS = [ { 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: { @@ -119,6 +126,7 @@ const TOOLS = [ { 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: { @@ -129,6 +137,7 @@ const TOOLS = [ { 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: {}, @@ -137,6 +146,7 @@ const TOOLS = [ { 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: { @@ -149,6 +159,7 @@ const TOOLS = [ { 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: { @@ -161,6 +172,7 @@ const TOOLS = [ { 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: { @@ -173,6 +185,7 @@ const TOOLS = [ { 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: { diff --git a/mcp-server/test.js b/mcp-server/test.js index 7b73d844..820ba6b8 100644 --- a/mcp-server/test.js +++ b/mcp-server/test.js @@ -9,6 +9,8 @@ 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 { findMarkdownFiles, getNote, searchNotes, vaultContext, } from './vault.js' @@ -305,6 +307,34 @@ describe('requireVaultPath', () => { }) 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`) + } + } finally { + await closeMcpClient(client, stderr) + } + }) + it('exits when the MCP client closes stdin', async () => { const child = spawn(process.execPath, ['index.js'], { cwd: MCP_SERVER_DIR, @@ -332,6 +362,41 @@ describe('stdio process lifecycle', () => { }) }) +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')