fix: mark MCP tools approval-safe

This commit is contained in:
lucaronin
2026-05-15 11:19:07 +02:00
parent 7e50da7816
commit 3695b6f3fe
2 changed files with 78 additions and 0 deletions

View File

@@ -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: {

View File

@@ -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')